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 = ' ') {
const escape = (text: string) => text.replace(/`/g, '\\`').replace(/\\/g, '\\\\');
const lines = text.split('\n');
if (lines.length === 1)
return '`' + text.replace(/`/g, '\\`').replace(/\${/g, '\\${') + '`';
return '`\n' + lines.map(line => indent + line.replace(/`/g, '\\`').replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``;
return '`' + escape(text) + '`';
return '`\n' + lines.map(line => indent + escape(line).replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``;
}
function isMultilineString(text: string) {

View File

@ -144,8 +144,8 @@ function toAriaNode(element: Element): AriaNode | null {
return result;
}
export function renderedAriaTree(rootElement: Element): string {
return renderAriaTree(generateAriaTree(rootElement));
export function renderedAriaTree(rootElement: Element, options?: { mode?: 'raw' | 'regex' }): string {
return renderAriaTree(generateAriaTree(rootElement), options);
}
function normalizeStringChildren(rootA11yNode: AriaNode) {
@ -209,11 +209,8 @@ export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode
return {
matches,
received: {
raw: renderAriaTree(root),
regex: renderAriaTree(root, {
includeText,
renderString: convertToBestGuessRegex
}),
raw: renderAriaTree(root, { mode: 'raw' }),
regex: renderAriaTree(root, { mode: 'regex' }),
}
};
}
@ -283,15 +280,10 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean {
return !!results.length;
}
type RenderAriaTreeOptions = {
includeText?: (node: AriaNode, text: string) => boolean;
renderString?: (text: string) => string | null;
};
export function renderAriaTree(ariaNode: AriaNode, options?: RenderAriaTreeOptions): string {
export function renderAriaTree(ariaNode: AriaNode, options?: { mode?: 'raw' | 'regex' }): string {
const lines: string[] = [];
const includeText = options?.includeText || (() => true);
const renderString = options?.renderString || (str => str);
const includeText = options?.mode === 'regex' ? textContributesInfo : () => true;
const renderString = options?.mode === 'regex' ? convertToBestGuessRegex : (str: string) => str;
const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string) => {
if (typeof ariaNode === 'string') {
if (parentAriaNode && !includeText(parentAriaNode, ariaNode))
@ -306,7 +298,7 @@ export function renderAriaTree(ariaNode: AriaNode, options?: RenderAriaTreeOptio
if (ariaNode.name) {
const name = renderString(ariaNode.name);
if (name)
key += ' ' + yamlQuoteFragment(name);
key += ' ' + (name.startsWith('/') && name.endsWith('/') ? name : yamlQuoteFragment(name));
}
if (ariaNode.checked === 'mixed')
key += ` [checked=mixed]`;
@ -353,31 +345,45 @@ export function renderAriaTree(ariaNode: AriaNode, options?: RenderAriaTreeOptio
function convertToBestGuessRegex(text: string): string {
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.
// 2+ digits: [Issue 22, 22.3, 2.33, 2,333]
{ regex: /\b\d{2,}\b/g, replacement: '\\d+' },
{ regex: /\b\{2,}\.\d+\b/g, replacement: '\\d+\\.\\d+' },
{ regex: /\b\d+\.\d{2,}\b/g, replacement: '\\d+\\.\\d+' },
{ regex: /\b\d+,\d+\b/g, replacement: '\\d+,\\d+' },
// 2ms, 20s
{ regex: /\b\d+[hms]+\b/g, replacement: '\\d+[hms]+' },
{ regex: /\b[\d,.]+[hms]+\b/g, replacement: '[\\d,.]+[hms]+' },
{ regex: /\b\d+,\d+\b/, replacement: '\\d+,\\d+' },
{ regex: /\b\d+\.\d{2,}\b/, replacement: '\\d+\\.\\d+' },
{ regex: /\b\d{2,}\.\d+\b/, replacement: '\\d+\\.\\d+' },
{ regex: /\b\d{2,}\b/, replacement: '\\d+' },
];
let result = escapeRegExp(text);
let hasDynamicContent = false;
let pattern = '';
let lastIndex = 0;
for (const { regex, replacement } of dynamicContent) {
if (regex.test(result)) {
result = result.replace(regex, replacement);
hasDynamicContent = true;
const combinedRegex = new RegExp(dynamicContent.map(r => '(' + r.regex.source + ')').join('|'), 'g');
text.replace(combinedRegex, (match, ...args) => {
const offset = args[args.length - 2];
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)
return false;

View File

@ -212,10 +212,10 @@ export class InjectedScript {
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)
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[] {

View File

@ -715,7 +715,7 @@ class TextAssertionTool implements RecorderTool {
name: 'assertSnapshot',
selector: this._hoverHighlight.selector,
signals: [],
snapshot: this._recorder.injectedScript.ariaSnapshot(target),
snapshot: this._recorder.injectedScript.ariaSnapshot(target, { mode: 'regex' }),
};
} else {
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)
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');
await fs.promises.mkdir(path.dirname(patchFile), { recursive: true });

View File

@ -39,4 +39,24 @@ test.describe(() => {
await expect.poll(() =>
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: /Time \\d+:\\d+/
+ - listitem: /Year \\d+/
+ - listitem: /Duration \\d+[hms]+/
+ - listitem: /Duration \\d+[hmsp]+/
+ - listitem: /\\d+,\\d+/
+ - listitem: /2,\\d+\\.\\d+/
+ - listitem: /\\d+,\\d+\\.\\d+/
+ - listitem: /Total \\d+/
+ - 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 }) => {
await page.setContent(\`<ul>
<button>Click: me</button>
<button>Click: 123</button>
<li>Item: 1</li>
<li>Item {a: b}</li>
</ul>\`);
@ -148,7 +149,7 @@ test('should generate baseline with special characters', async ({ runInlineTest
const data = fs.readFileSync(patchPath, 'utf-8');
expect(data).toBe(`--- a/a.spec.ts
+++ b/a.spec.ts
@@ -6,6 +6,11 @@
@@ -7,6 +7,12 @@
<li>Item: 1</li>
<li>Item {a: b}</li>
</ul>\`);
@ -156,6 +157,7 @@ test('should generate baseline with special characters', async ({ runInlineTest
+ await expect(page.locator('body')).toMatchAriaSnapshot(\`
+ - list:
+ - 'button "Click: me"'
+ - 'button /Click: \\\\d+/'
+ - listitem: \"Item: 1\"
+ - listitem: \"Item {a: b}\"
+ \`);