chore: generate aria name regex when possible (#33373)
This commit is contained in:
parent
676f014b5f
commit
a2e901e080
|
@ -275,10 +275,11 @@ ${body}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function quoteMultiline(text: string, indent = ' ') {
|
export function quoteMultiline(text: string, indent = ' ') {
|
||||||
|
const escape = (text: string) => text.replace(/`/g, '\\`').replace(/\\/g, '\\\\');
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
if (lines.length === 1)
|
if (lines.length === 1)
|
||||||
return '`' + text.replace(/`/g, '\\`').replace(/\${/g, '\\${') + '`';
|
return '`' + escape(text) + '`';
|
||||||
return '`\n' + lines.map(line => indent + line.replace(/`/g, '\\`').replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``;
|
return '`\n' + lines.map(line => indent + escape(line).replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMultilineString(text: string) {
|
function isMultilineString(text: string) {
|
||||||
|
|
|
@ -144,8 +144,8 @@ function toAriaNode(element: Element): AriaNode | null {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderedAriaTree(rootElement: Element): string {
|
export function renderedAriaTree(rootElement: Element, options?: { mode?: 'raw' | 'regex' }): string {
|
||||||
return renderAriaTree(generateAriaTree(rootElement));
|
return renderAriaTree(generateAriaTree(rootElement), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStringChildren(rootA11yNode: AriaNode) {
|
function normalizeStringChildren(rootA11yNode: AriaNode) {
|
||||||
|
@ -209,11 +209,8 @@ export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode
|
||||||
return {
|
return {
|
||||||
matches,
|
matches,
|
||||||
received: {
|
received: {
|
||||||
raw: renderAriaTree(root),
|
raw: renderAriaTree(root, { mode: 'raw' }),
|
||||||
regex: renderAriaTree(root, {
|
regex: renderAriaTree(root, { mode: 'regex' }),
|
||||||
includeText,
|
|
||||||
renderString: convertToBestGuessRegex
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -283,15 +280,10 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean {
|
||||||
return !!results.length;
|
return !!results.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RenderAriaTreeOptions = {
|
export function renderAriaTree(ariaNode: AriaNode, options?: { mode?: 'raw' | 'regex' }): string {
|
||||||
includeText?: (node: AriaNode, text: string) => boolean;
|
|
||||||
renderString?: (text: string) => string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function renderAriaTree(ariaNode: AriaNode, options?: RenderAriaTreeOptions): string {
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const includeText = options?.includeText || (() => true);
|
const includeText = options?.mode === 'regex' ? textContributesInfo : () => true;
|
||||||
const renderString = options?.renderString || (str => str);
|
const renderString = options?.mode === 'regex' ? convertToBestGuessRegex : (str: string) => str;
|
||||||
const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string) => {
|
const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string) => {
|
||||||
if (typeof ariaNode === 'string') {
|
if (typeof ariaNode === 'string') {
|
||||||
if (parentAriaNode && !includeText(parentAriaNode, ariaNode))
|
if (parentAriaNode && !includeText(parentAriaNode, ariaNode))
|
||||||
|
@ -306,7 +298,7 @@ export function renderAriaTree(ariaNode: AriaNode, options?: RenderAriaTreeOptio
|
||||||
if (ariaNode.name) {
|
if (ariaNode.name) {
|
||||||
const name = renderString(ariaNode.name);
|
const name = renderString(ariaNode.name);
|
||||||
if (name)
|
if (name)
|
||||||
key += ' ' + yamlQuoteFragment(name);
|
key += ' ' + (name.startsWith('/') && name.endsWith('/') ? name : yamlQuoteFragment(name));
|
||||||
}
|
}
|
||||||
if (ariaNode.checked === 'mixed')
|
if (ariaNode.checked === 'mixed')
|
||||||
key += ` [checked=mixed]`;
|
key += ` [checked=mixed]`;
|
||||||
|
@ -353,31 +345,45 @@ export function renderAriaTree(ariaNode: AriaNode, options?: RenderAriaTreeOptio
|
||||||
|
|
||||||
function convertToBestGuessRegex(text: string): string {
|
function convertToBestGuessRegex(text: string): string {
|
||||||
const dynamicContent = [
|
const dynamicContent = [
|
||||||
|
// 2mb
|
||||||
|
{ regex: /\b[\d,.]+[bkmBKM]+\b/, replacement: '[\\d,.]+[bkmBKM]+' },
|
||||||
|
// 2ms, 20s
|
||||||
|
{ regex: /\b\d+[hmsp]+\b/, replacement: '\\d+[hmsp]+' },
|
||||||
|
{ regex: /\b[\d,.]+[hmsp]+\b/, replacement: '[\\d,.]+[hmsp]+' },
|
||||||
// Do not replace single digits with regex by default.
|
// Do not replace single digits with regex by default.
|
||||||
// 2+ digits: [Issue 22, 22.3, 2.33, 2,333]
|
// 2+ digits: [Issue 22, 22.3, 2.33, 2,333]
|
||||||
{ regex: /\b\d{2,}\b/g, replacement: '\\d+' },
|
{ regex: /\b\d+,\d+\b/, replacement: '\\d+,\\d+' },
|
||||||
{ regex: /\b\{2,}\.\d+\b/g, replacement: '\\d+\\.\\d+' },
|
{ regex: /\b\d+\.\d{2,}\b/, replacement: '\\d+\\.\\d+' },
|
||||||
{ regex: /\b\d+\.\d{2,}\b/g, replacement: '\\d+\\.\\d+' },
|
{ regex: /\b\d{2,}\.\d+\b/, replacement: '\\d+\\.\\d+' },
|
||||||
{ regex: /\b\d+,\d+\b/g, replacement: '\\d+,\\d+' },
|
{ regex: /\b\d{2,}\b/, replacement: '\\d+' },
|
||||||
// 2ms, 20s
|
|
||||||
{ regex: /\b\d+[hms]+\b/g, replacement: '\\d+[hms]+' },
|
|
||||||
{ regex: /\b[\d,.]+[hms]+\b/g, replacement: '[\\d,.]+[hms]+' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = escapeRegExp(text);
|
let pattern = '';
|
||||||
let hasDynamicContent = false;
|
let lastIndex = 0;
|
||||||
|
|
||||||
for (const { regex, replacement } of dynamicContent) {
|
const combinedRegex = new RegExp(dynamicContent.map(r => '(' + r.regex.source + ')').join('|'), 'g');
|
||||||
if (regex.test(result)) {
|
text.replace(combinedRegex, (match, ...args) => {
|
||||||
result = result.replace(regex, replacement);
|
const offset = args[args.length - 2];
|
||||||
hasDynamicContent = true;
|
const groups = args.slice(0, -2);
|
||||||
|
pattern += escapeRegExp(text.slice(lastIndex, offset));
|
||||||
|
for (let i = 0; i < groups.length; i++) {
|
||||||
|
if (groups[i]) {
|
||||||
|
const { replacement } = dynamicContent[i];
|
||||||
|
pattern += replacement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
lastIndex = offset + match.length;
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
if (!pattern)
|
||||||
|
return text;
|
||||||
|
|
||||||
return hasDynamicContent ? String(new RegExp(result)) : text;
|
pattern += escapeRegExp(text.slice(lastIndex));
|
||||||
|
return String(new RegExp(pattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
function includeText(node: AriaNode, text: string): boolean {
|
function textContributesInfo(node: AriaNode, text: string): boolean {
|
||||||
if (!text.length)
|
if (!text.length)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|
|
@ -212,10 +212,10 @@ export class InjectedScript {
|
||||||
return new Set<Element>(result.map(r => r.element));
|
return new Set<Element>(result.map(r => r.element));
|
||||||
}
|
}
|
||||||
|
|
||||||
ariaSnapshot(node: Node): string {
|
ariaSnapshot(node: Node, options?: { mode?: 'raw' | 'regex' }): string {
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE)
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
||||||
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
|
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
|
||||||
return renderedAriaTree(node as Element);
|
return renderedAriaTree(node as Element, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
|
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
|
||||||
|
|
|
@ -715,7 +715,7 @@ class TextAssertionTool implements RecorderTool {
|
||||||
name: 'assertSnapshot',
|
name: 'assertSnapshot',
|
||||||
selector: this._hoverHighlight.selector,
|
selector: this._hoverHighlight.selector,
|
||||||
signals: [],
|
signals: [],
|
||||||
snapshot: this._recorder.injectedScript.ariaSnapshot(target),
|
snapshot: this._recorder.injectedScript.ariaSnapshot(target, { mode: 'regex' }),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
|
this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
|
||||||
|
|
|
@ -86,7 +86,7 @@ export async function applySuggestedRebaselines(config: FullConfigInternal) {
|
||||||
for (const range of ranges)
|
for (const range of ranges)
|
||||||
result = result.substring(0, range.start) + range.newText + result.substring(range.end);
|
result = result.substring(0, range.start) + range.newText + result.substring(range.end);
|
||||||
|
|
||||||
const relativeName = path.relative(process.cwd(), fileName);
|
const relativeName = path.relative(process.cwd(), fileName).replace(/\\/g, '/');
|
||||||
|
|
||||||
const patchFile = path.join(project.project.outputDir, 'rebaselines.patch');
|
const patchFile = path.join(project.project.outputDir, 'rebaselines.patch');
|
||||||
await fs.promises.mkdir(path.dirname(patchFile), { recursive: true });
|
await fs.promises.mkdir(path.dirname(patchFile), { recursive: true });
|
||||||
|
|
|
@ -39,4 +39,24 @@ test.describe(() => {
|
||||||
await expect.poll(() =>
|
await expect.poll(() =>
|
||||||
recorder.text('C#')).toContain(`await Expect(page.GetByRole(AriaRole.Button)).ToMatchAriaSnapshotAsync("- button \\"Submit\\"");`);
|
recorder.text('C#')).toContain(`await Expect(page.GetByRole(AriaRole.Button)).ToMatchAriaSnapshotAsync("- button \\"Submit\\"");`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should generate regex in aria snapshot', async ({ openRecorder }) => {
|
||||||
|
const { recorder } = await openRecorder();
|
||||||
|
await recorder.setContentAndWait(`<main><button>Submit 123</button></main>`);
|
||||||
|
|
||||||
|
await recorder.page.click('x-pw-tool-item.snapshot');
|
||||||
|
await recorder.page.hover('button');
|
||||||
|
await recorder.trustedClick();
|
||||||
|
|
||||||
|
await expect.poll(() =>
|
||||||
|
recorder.text('JavaScript')).toContain(`await expect(page.getByRole('button')).toMatchAriaSnapshot(\`- button /Submit \\\\d+/\`);`);
|
||||||
|
await expect.poll(() =>
|
||||||
|
recorder.text('Python')).toContain(`expect(page.get_by_role("button")).to_match_aria_snapshot("- button /Submit \\\\d+/")`);
|
||||||
|
await expect.poll(() =>
|
||||||
|
recorder.text('Python Async')).toContain(`await expect(page.get_by_role(\"button\")).to_match_aria_snapshot("- button /Submit \\\\d+/")`);
|
||||||
|
await expect.poll(() =>
|
||||||
|
recorder.text('Java')).toContain(`assertThat(page.getByRole(AriaRole.BUTTON)).matchesAriaSnapshot("- button /Submit \\\\d+/");`);
|
||||||
|
await expect.poll(() =>
|
||||||
|
recorder.text('C#')).toContain(`await Expect(page.GetByRole(AriaRole.Button)).ToMatchAriaSnapshotAsync("- button /Submit \\\\d+/");`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -116,12 +116,12 @@ test('should generate baseline with regex', async ({ runInlineTest }, testInfo)
|
||||||
+ - listitem: Item 2
|
+ - listitem: Item 2
|
||||||
+ - listitem: /Time \\d+:\\d+/
|
+ - listitem: /Time \\d+:\\d+/
|
||||||
+ - listitem: /Year \\d+/
|
+ - listitem: /Year \\d+/
|
||||||
+ - listitem: /Duration \\d+[hms]+/
|
+ - listitem: /Duration \\d+[hmsp]+/
|
||||||
+ - listitem: /\\d+,\\d+/
|
+ - listitem: /\\d+,\\d+/
|
||||||
+ - listitem: /2,\\d+\\.\\d+/
|
+ - listitem: /\\d+,\\d+\\.\\d+/
|
||||||
+ - listitem: /Total \\d+/
|
+ - listitem: /Total \\d+/
|
||||||
+ - listitem: /Regex 1/
|
+ - listitem: /Regex 1/
|
||||||
+ - listitem: /\\/Regex \\d+[hms]+\\//
|
+ - listitem: /\\/Regex \\d+[hmsp]+\\//
|
||||||
+ \`);
|
+ \`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -135,6 +135,7 @@ test('should generate baseline with special characters', async ({ runInlineTest
|
||||||
test('test', async ({ page }) => {
|
test('test', async ({ page }) => {
|
||||||
await page.setContent(\`<ul>
|
await page.setContent(\`<ul>
|
||||||
<button>Click: me</button>
|
<button>Click: me</button>
|
||||||
|
<button>Click: 123</button>
|
||||||
<li>Item: 1</li>
|
<li>Item: 1</li>
|
||||||
<li>Item {a: b}</li>
|
<li>Item {a: b}</li>
|
||||||
</ul>\`);
|
</ul>\`);
|
||||||
|
@ -148,7 +149,7 @@ test('should generate baseline with special characters', async ({ runInlineTest
|
||||||
const data = fs.readFileSync(patchPath, 'utf-8');
|
const data = fs.readFileSync(patchPath, 'utf-8');
|
||||||
expect(data).toBe(`--- a/a.spec.ts
|
expect(data).toBe(`--- a/a.spec.ts
|
||||||
+++ b/a.spec.ts
|
+++ b/a.spec.ts
|
||||||
@@ -6,6 +6,11 @@
|
@@ -7,6 +7,12 @@
|
||||||
<li>Item: 1</li>
|
<li>Item: 1</li>
|
||||||
<li>Item {a: b}</li>
|
<li>Item {a: b}</li>
|
||||||
</ul>\`);
|
</ul>\`);
|
||||||
|
@ -156,6 +157,7 @@ test('should generate baseline with special characters', async ({ runInlineTest
|
||||||
+ await expect(page.locator('body')).toMatchAriaSnapshot(\`
|
+ await expect(page.locator('body')).toMatchAriaSnapshot(\`
|
||||||
+ - list:
|
+ - list:
|
||||||
+ - 'button "Click: me"'
|
+ - 'button "Click: me"'
|
||||||
|
+ - 'button /Click: \\\\d+/'
|
||||||
+ - listitem: \"Item: 1\"
|
+ - listitem: \"Item: 1\"
|
||||||
+ - listitem: \"Item {a: b}\"
|
+ - listitem: \"Item {a: b}\"
|
||||||
+ \`);
|
+ \`);
|
||||||
|
|
Loading…
Reference in New Issue