chore: generate aria name regex when possible (#33373)

This commit is contained in:
Pavel Feldman 2024-10-31 11:25:38 -07:00 committed by GitHub
parent 676f014b5f
commit a2e901e080
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 71 additions and 42 deletions

View File

@ -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) {

View File

@ -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;

View File

@ -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[] {

View File

@ -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 });

View File

@ -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 });

View File

@ -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+/");`);
});
}); });

View File

@ -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}\"
+ \`); + \`);