diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index 82f3119d9c58b..d2a4df8e0753e 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -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; } diff --git a/packages/playwright-core/src/server/injected/yaml.ts b/packages/playwright-core/src/server/injected/yaml.ts new file mode 100644 index 0000000000000..0030ad1a26e2b --- /dev/null +++ b/packages/playwright-core/src/server/injected/yaml.ts @@ -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; +} diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index 23c947cc49e82..37db89fcc6247 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -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); +} diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 496c1b9a08cfd..73e206e62fb3c 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -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 }; } diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 86843d886a82e..8881fab97278c 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -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 `); }); diff --git a/tests/playwright-test/ui-mode-test-output.spec.ts b/tests/playwright-test/ui-mode-test-output.spec.ts index b10c02d08a43f..46cdc2e478c7c 100644 --- a/tests/playwright-test/ui-mode-test-output.spec.ts +++ b/tests/playwright-test/ui-mode-test-output.spec.ts @@ -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: "/:1 Object {a: 1}/" + - listitem: "/:4 Date/" + - listitem: "/:5 Regex \/a\//" + - listitem: "/:6 Number 0 one 2/" + - listitem: "/:7 Download the React DevTools for a better development experience: https:\/\/fb\.me\/react-devtools/" + - listitem: "/: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'); diff --git a/tests/playwright-test/update-aria-snapshot.spec.ts b/tests/playwright-test/update-aria-snapshot.spec.ts index a5f56ea28e96a..203fda16a58c8 100644 --- a/tests/playwright-test/update-aria-snapshot.spec.ts +++ b/tests/playwright-test/update-aria-snapshot.spec.ts @@ -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(\`
    +
  • Item 1
  • +
  • Item 2
  • +
  • Time 15:30
  • +
  • Year 2022
  • +
  • Duration 12ms
  • +
  • 22,333
  • +
  • 2,333.79
  • +
  • Total 22
  • +
  • /Regex 1/
  • +
  • /Regex 22ms/
  • +
\`); + 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 @@ +
  • /Regex 1/
  • +
  • /Regex 22ms/
  • + \`); +- 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(\`
      + +
    • Item: 1
    • +
    • Item {a: b}
    • +
    \`); + 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 @@ +
  • Item: 1
  • +
  • Item {a: b}
  • + \`); +- await expect(page.locator('body')).toMatchAriaSnapshot(\`\`); ++ await expect(page.locator('body')).toMatchAriaSnapshot(\` ++ - list: ++ - 'button "Click: me"' ++ - listitem: \"Item: 1\" ++ - listitem: \"Item {a: b}\" ++ \`); + }); + +`); +});