chore: suggest aria snapshots w/ regex (#33334)

This commit is contained in:
Pavel Feldman 2024-10-29 16:19:08 -07:00 committed by GitHub
parent 3b1883444d
commit 9ce401d44a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 357 additions and 50 deletions

View file

@ -17,6 +17,8 @@
import * as roleUtils from './roleUtils';
import { getElementComputedStyle } from './domUtils';
import type { AriaRole } from './roleUtils';
import { escapeRegExp, longestCommonSubstring } from '@isomorphic/stringUtils';
import { yamlEscapeStringIfNeeded, yamlQuoteFragment } from './yaml';
type AriaProps = {
checked?: boolean | 'mixed';
@ -89,7 +91,7 @@ export function generateAriaTree(rootElement: Element): AriaNode {
if (treatAsBlock)
ariaNode.children.push(treatAsBlock);
if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0])
if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0])
ariaNode.children = [];
}
@ -180,10 +182,19 @@ function matchesText(text: string | undefined, template: RegExp | string | undef
return !!text.match(template);
}
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } {
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: { raw: string, regex: string } } {
const root = generateAriaTree(rootElement);
const matches = matchesNodeDeep(root, template);
return { matches, received: renderAriaTree(root) };
return {
matches,
received: {
raw: renderAriaTree(root),
regex: renderAriaTree(root, {
includeText,
renderString: convertToBestGuessRegex
}),
}
};
}
function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean {
@ -251,62 +262,111 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean {
return !!results.length;
}
export function renderAriaTree(ariaNode: AriaNode, options?: { noText?: boolean }): string {
type RenderAriaTreeOptions = {
includeText?: (node: AriaNode, text: string) => boolean;
renderString?: (text: string) => string | null;
};
export function renderAriaTree(ariaNode: AriaNode, options?: RenderAriaTreeOptions): string {
const lines: string[] = [];
const visit = (ariaNode: AriaNode | string, indent: string) => {
const includeText = options?.includeText || (() => true);
const renderString = options?.renderString || (str => str);
const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string) => {
if (typeof ariaNode === 'string') {
if (!options?.noText)
lines.push(indent + '- text: ' + quoteYamlString(ariaNode));
if (parentAriaNode && !includeText(parentAriaNode, ariaNode))
return;
const text = renderString(ariaNode);
if (text)
lines.push(indent + '- text: ' + text);
return;
}
let line = `${indent}- ${ariaNode.role}`;
if (ariaNode.name)
line += ` ${quoteYamlString(ariaNode.name)}`;
let key = ariaNode.role;
if (ariaNode.name) {
const name = renderString(ariaNode.name);
if (name)
key += ' ' + yamlQuoteFragment(name);
}
if (ariaNode.checked === 'mixed')
line += ` [checked=mixed]`;
key += ` [checked=mixed]`;
if (ariaNode.checked === true)
line += ` [checked]`;
key += ` [checked]`;
if (ariaNode.disabled)
line += ` [disabled]`;
key += ` [disabled]`;
if (ariaNode.expanded)
line += ` [expanded]`;
key += ` [expanded]`;
if (ariaNode.level)
line += ` [level=${ariaNode.level}]`;
key += ` [level=${ariaNode.level}]`;
if (ariaNode.pressed === 'mixed')
line += ` [pressed=mixed]`;
key += ` [pressed=mixed]`;
if (ariaNode.pressed === true)
line += ` [pressed]`;
key += ` [pressed]`;
if (ariaNode.selected === true)
line += ` [selected]`;
key += ` [selected]`;
const escapedKey = indent + '- ' + yamlEscapeStringIfNeeded(key, '\'');
if (!ariaNode.children.length) {
lines.push(line);
lines.push(escapedKey);
} else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string') {
if (!options?.noText)
line += ': ' + quoteYamlString(ariaNode.children[0]);
lines.push(line);
const text = includeText(ariaNode, ariaNode.children[0]) ? renderString(ariaNode.children[0] as string) : null;
if (text)
lines.push(escapedKey + ': ' + yamlEscapeStringIfNeeded(text, '"'));
else
lines.push(escapedKey);
} else {
lines.push(line + ':');
lines.push(escapedKey + ':');
for (const child of ariaNode.children || [])
visit(child, indent + ' ');
visit(child, ariaNode, indent + ' ');
}
};
if (ariaNode.role === 'fragment') {
// Render fragment.
for (const child of ariaNode.children || [])
visit(child, '');
visit(child, ariaNode, '');
} else {
visit(ariaNode, '');
visit(ariaNode, null, '');
}
return lines.join('\n');
}
function quoteYamlString(str: string) {
return `"${str
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')}"`;
function convertToBestGuessRegex(text: string): string {
const dynamicContent = [
// 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]+' },
];
let result = escapeRegExp(text);
let hasDynamicContent = false;
for (const { regex, replacement } of dynamicContent) {
if (regex.test(result)) {
result = result.replace(regex, replacement);
hasDynamicContent = true;
}
}
return hasDynamicContent ? String(new RegExp(result)) : text;
}
function includeText(node: AriaNode, text: string): boolean {
if (!text.length)
return false;
if (!node.name)
return true;
// Figure out if text adds any value.
const substr = longestCommonSubstring(text, node.name);
let filtered = text;
while (substr && filtered.includes(substr))
filtered = filtered.replace(substr, '');
return filtered.trim().length / text.length > 0.1;
}

View file

@ -0,0 +1,107 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function yamlEscapeStringIfNeeded(str: string, quote = '"'): string {
if (!yamlStringNeedsQuotes(str))
return str;
return yamlEscapeString(str, quote);
}
export function yamlEscapeString(str: string, quote = '"'): string {
return quote + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => {
switch (c) {
case '\\':
return '\\\\';
case '"':
return quote === '"' ? '\\"' : '"';
case '\'':
return quote === '\'' ? '\\\'' : '\'';
case '\b':
return '\\b';
case '\f':
return '\\f';
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
default:
const code = c.charCodeAt(0);
return '\\x' + code.toString(16).padStart(2, '0');
}
}) + quote;
}
export function yamlQuoteFragment(str: string, quote = '"'): string {
return quote + str.replace(/['"]/g, c => {
switch (c) {
case '"':
return quote === '"' ? '\\"' : '"';
case '\'':
return quote === '\'' ? '\\\'' : '\'';
default:
return c;
}
}) + quote;
}
function yamlStringNeedsQuotes(str: string): boolean {
if (str.length === 0)
return true;
// Strings with leading or trailing whitespace need quotes
if (/^\s|\s$/.test(str))
return true;
// Strings containing control characters need quotes
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
return true;
// Strings starting with '-' followed by a space need quotes
if (/^-\s/.test(str))
return true;
// Strings that start with a special indicator character need quotes
if (/^[&*].*/.test(str))
return true;
// Strings containing ':' followed by a space or at the end need quotes
if (/:(\s|$)/.test(str))
return true;
// Strings containing '#' preceded by a space need quotes (comment indicator)
if (/\s#/.test(str))
return true;
// Strings that contain line breaks need quotes
if (/[\n\r]/.test(str))
return true;
// Strings starting with '?' or '!' (directives) need quotes
if (/^[?!]/.test(str))
return true;
// Strings starting with '>' or '|' (block scalar indicators) need quotes
if (/^[>|]/.test(str))
return true;
// Strings containing special characters that could cause ambiguity
if (/[{}`]/.test(str))
return true;
return false;
}

View file

@ -140,3 +140,32 @@ export function escapeHTMLAttribute(s: string): string {
export function escapeHTML(s: string): string {
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
}
export function longestCommonSubstring(s1: string, s2: string): string {
const n = s1.length;
const m = s2.length;
let maxLen = 0;
let endingIndex = 0;
// Initialize a 2D array with zeros
const dp = Array(n + 1)
.fill(null)
.map(() => Array(m + 1).fill(0));
// Build the dp table
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (s1[i - 1] === s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
if (dp[i][j] > maxLen) {
maxLen = dp[i][j];
endingIndex = i;
}
}
}
}
// Extract the longest common substring
return s1.slice(endingIndex - maxLen, endingIndex);
}

View file

@ -70,11 +70,25 @@ export async function toMatchAriaSnapshot(
const timeout = options.timeout ?? this.timeout;
expected = unshift(expected);
const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout });
const typedReceived = received as {
raw: string;
noText: string;
regex: string;
} | typeof kNoElementsFoundError;
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError;
const notFound = typedReceived === kNoElementsFoundError;
if (notFound) {
return {
pass: this.isNot,
message: () => messagePrefix + `Expected: ${this.utils.printExpected(expected)}\nReceived: ${EXPECTED_COLOR('not found')}` + callLogText(log),
name: 'toMatchAriaSnapshot',
expected,
};
}
const escapedExpected = escapePrivateUsePoints(expected);
const escapedReceived = escapePrivateUsePoints(received);
const escapedReceived = escapePrivateUsePoints(typedReceived.raw);
const message = () => {
if (pass) {
if (notFound)
@ -91,7 +105,7 @@ export async function toMatchAriaSnapshot(
if (!this.isNot && pass === this.isNot && generateNewBaseline) {
// Only rebaseline failed snapshots.
const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${indent(received, '${indent} ')}\n\${indent}\`)`;
const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${indent(typedReceived.regex, '${indent} ')}\n\${indent}\`)`;
return { pass: this.isNot, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline };
}

View file

@ -64,8 +64,8 @@ it('should snapshot list with accessible name', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- list "my list":
- listitem: "one"
- listitem: "two"
- listitem: one
- listitem: two
`);
});
@ -92,7 +92,7 @@ it('should allow text nodes', async ({ page }) => {
await checkAndMatchSnapshot(page.locator('body'), `
- heading "Microsoft" [level=1]
- text: "Open source projects and samples from Microsoft"
- text: Open source projects and samples from Microsoft
`);
});
@ -105,7 +105,7 @@ it('should snapshot details visibility', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- group: "Summary"
- group: Summary
`);
});
@ -145,10 +145,10 @@ it('should snapshot integration', async ({ page }) => {
await checkAndMatchSnapshot(page.locator('body'), `
- heading "Microsoft" [level=1]
- text: "Open source projects and samples from Microsoft"
- text: Open source projects and samples from Microsoft
- list:
- listitem:
- group: "Verified"
- group: Verified
- listitem:
- link "Sponsor"
`);
@ -164,7 +164,7 @@ it('should support multiline text', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- paragraph: "Line 1 Line 2 Line 3"
- paragraph: Line 1 Line 2 Line 3
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- paragraph: |
@ -180,7 +180,7 @@ it('should concatenate span text', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- text: "One Two Three"
- text: One Two Three
`);
});
@ -190,7 +190,7 @@ it('should concatenate span text 2', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- text: "One Two Three"
- text: One Two Three
`);
});
@ -200,7 +200,7 @@ it('should concatenate div text with spaces', async ({ page }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- text: "One Two Three"
- text: One Two Three
`);
});
@ -362,12 +362,12 @@ it('should snapshot inner text', async ({ page }) => {
await checkAndMatchSnapshot(page.locator('body'), `
- listitem:
- text: "a.test.ts"
- text: a.test.ts
- button "Run"
- button "Show source"
- button "Watch"
- listitem:
- text: "snapshot 30ms"
- text: snapshot 30ms
- button "Run"
- button "Show source"
- button "Watch"
@ -382,7 +382,7 @@ it('should include pseudo codepoints', async ({ page, server }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- paragraph: "\ueab2hello"
- paragraph: \ueab2hello
`);
});
@ -396,7 +396,7 @@ it('check aria-hidden text', async ({ page, server }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- paragraph: "hello"
- paragraph: hello
`);
});
@ -410,6 +410,6 @@ it('should ignore presentation and none roles', async ({ page, server }) => {
`);
await checkAndMatchSnapshot(page.locator('body'), `
- list: "hello world"
- list: hello world
`);
});

View file

@ -145,6 +145,17 @@ test('should format console messages in page', async ({ runUITest }, testInfo) =
'Failed to load resource: net::ERR_CONNECTION_REFUSED',
]);
await expect(page.locator('.console-tab')).toMatchAriaSnapshot(`
- list:
- listitem: "/<anonymous>:1 Object {a: 1}/"
- listitem: "/<anonymous>:4 Date/"
- listitem: "/<anonymous>:5 Regex \/a\//"
- listitem: "/<anonymous>:6 Number 0 one 2/"
- listitem: "/<anonymous>:7 Download the React DevTools for a better development experience: https:\/\/fb\.me\/react-devtools/"
- listitem: "/<anonymous>:8 Array of values/"
- listitem: "/Failed to load resource: net::ERR_CONNECTION_REFUSED/"
`);
const label = page.getByText('React DevTools');
await expect(label).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(label).toHaveCSS('font-weight', '700');

View file

@ -77,3 +77,89 @@ test('should update missing snapshots', async ({ runInlineTest }, testInfo) => {
`);
});
test('should generate baseline with regex', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Time 15:30</li>
<li>Year 2022</li>
<li>Duration 12ms</li>
<li>22,333</li>
<li>2,333.79</li>
<li>Total 22</li>
<li>/Regex 1/</li>
<li>/Regex 22ms/</li>
</ul>\`);
await expect(page.locator('body')).toMatchAriaSnapshot(\`\`);
});
`
});
expect(result.exitCode).toBe(0);
const patchPath = testInfo.outputPath('test-results/rebaselines.patch');
const data = fs.readFileSync(patchPath, 'utf-8');
expect(data).toBe(`--- a/a.spec.ts
+++ b/a.spec.ts
@@ -13,6 +13,18 @@
<li>/Regex 1/</li>
<li>/Regex 22ms/</li>
</ul>\`);
- await expect(page.locator('body')).toMatchAriaSnapshot(\`\`);
+ await expect(page.locator('body')).toMatchAriaSnapshot(\`
+ - list:
+ - listitem: Item 1
+ - listitem: Item 2
+ - listitem: /Time \\d+:\\d+/
+ - listitem: /Year \\d+/
+ - listitem: /Duration \\d+[hms]+/
+ - listitem: /\\d+,\\d+/
+ - listitem: /2,\\d+\\.\\d+/
+ - listitem: /Total \\d+/
+ - listitem: /Regex 1/
+ - listitem: /\\/Regex \\d+[hms]+\\//
+ \`);
});
`);
});
test('should generate baseline with special characters', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'a.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.setContent(\`<ul>
<button>Click: me</button>
<li>Item: 1</li>
<li>Item {a: b}</li>
</ul>\`);
await expect(page.locator('body')).toMatchAriaSnapshot(\`\`);
});
`
});
expect(result.exitCode).toBe(0);
const patchPath = testInfo.outputPath('test-results/rebaselines.patch');
const data = fs.readFileSync(patchPath, 'utf-8');
expect(data).toBe(`--- a/a.spec.ts
+++ b/a.spec.ts
@@ -6,6 +6,11 @@
<li>Item: 1</li>
<li>Item {a: b}</li>
</ul>\`);
- await expect(page.locator('body')).toMatchAriaSnapshot(\`\`);
+ await expect(page.locator('body')).toMatchAriaSnapshot(\`
+ - list:
+ - 'button "Click: me"'
+ - listitem: \"Item: 1\"
+ - listitem: \"Item {a: b}\"
+ \`);
});
`);
});