diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index c0e62d9df3..f63dbb7c37 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -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) { diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index 7e2a6bd309..b2a0de91e2 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -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; diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 6446d16966..6f0d8ee473 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -212,10 +212,10 @@ export class InjectedScript { return new Set(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[] { diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 1d50495c77..389ec04276 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -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 }); diff --git a/packages/playwright/src/runner/rebase.ts b/packages/playwright/src/runner/rebase.ts index d0d7a85c86..c76ce43420 100644 --- a/packages/playwright/src/runner/rebase.ts +++ b/packages/playwright/src/runner/rebase.ts @@ -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 }); diff --git a/tests/library/inspector/cli-codegen-aria.spec.ts b/tests/library/inspector/cli-codegen-aria.spec.ts index f99f65fd6f..840725067b 100644 --- a/tests/library/inspector/cli-codegen-aria.spec.ts +++ b/tests/library/inspector/cli-codegen-aria.spec.ts @@ -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(`
`); + + 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+/");`); + }); }); diff --git a/tests/playwright-test/update-aria-snapshot.spec.ts b/tests/playwright-test/update-aria-snapshot.spec.ts index 9f334f8355..8721bebcc9 100644 --- a/tests/playwright-test/update-aria-snapshot.spec.ts +++ b/tests/playwright-test/update-aria-snapshot.spec.ts @@ -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(\`\`); @@ -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 @@
  • Item: 1
  • Item {a: b}
  • \`); @@ -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}\" + \`);