From 135ed28740fdac3cf28dd4c1829fa60b00c3fc0d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 31 Oct 2024 17:14:11 -0700 Subject: [PATCH] chore: more yaml escaping tests (#33387) --- .../html-reporter/src/testCaseView.spec.tsx | 7 + .../src/server/ariaSnapshot.ts | 191 ++++++++++++++---- .../src/server/codegen/javascript.ts | 4 +- .../src/server/injected/ariaSnapshot.ts | 6 +- .../src/server/injected/yaml.ts | 16 +- .../src/utils/isomorphic/stringUtils.ts | 7 + .../src/matchers/toMatchAriaSnapshot.ts | 5 +- packages/playwright/src/runner/rebase.ts | 2 +- tests/page/page-aria-snapshot.spec.ts | 6 +- tests/page/to-match-aria-snapshot.spec.ts | 53 +++++ .../update-aria-snapshot.spec.ts | 20 +- 11 files changed, 246 insertions(+), 71 deletions(-) diff --git a/packages/html-reporter/src/testCaseView.spec.tsx b/packages/html-reporter/src/testCaseView.spec.tsx index 5a97f7bf23..a8ad110887 100644 --- a/packages/html-reporter/src/testCaseView.spec.tsx +++ b/packages/html-reporter/src/testCaseView.spec.tsx @@ -187,6 +187,10 @@ test('should correctly render links in attachments', async ({ mount }) => { await expect(body).toBeVisible(); await expect(body.locator('a').filter({ hasText: 'playwright.dev' })).toHaveAttribute('href', 'https://playwright.dev/docs/intro'); await expect(body.locator('a').filter({ hasText: 'github.com' })).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284'); + await expect(component).toMatchAriaSnapshot(` + - link "https://playwright.dev/docs/intro" + - link "https://github.com/microsoft/playwright/issues/31284" + `); }); test('should correctly render links in attachment name', async ({ mount }) => { @@ -194,6 +198,9 @@ test('should correctly render links in attachment name', async ({ mount }) => { const link = component.getByText('attachment with inline link').locator('a'); await expect(link).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284'); await expect(link).toHaveText('https://github.com/microsoft/playwright/issues/31284'); + await expect(component).toMatchAriaSnapshot(` + - link /https:\\/\\/github\\.com\\/microsoft\\/playwright\\/issues\\/\\d+/ + `); }); test('should correctly render prev and next', async ({ mount }) => { diff --git a/packages/playwright-core/src/server/ariaSnapshot.ts b/packages/playwright-core/src/server/ariaSnapshot.ts index 7a17b49752..744167048a 100644 --- a/packages/playwright-core/src/server/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/ariaSnapshot.ts @@ -16,7 +16,6 @@ import type { AriaTemplateNode, AriaTemplateRoleNode } from './injected/ariaSnapshot'; import { yaml } from '../utilsBundle'; -import type { AriaRole } from '@injected/roleUtils'; import { assert } from '../utils'; export function parseAriaSnapshot(text: string): AriaTemplateNode { @@ -29,7 +28,7 @@ export function parseAriaSnapshot(text: string): AriaTemplateNode { function populateNode(node: AriaTemplateRoleNode, container: any[]) { for (const object of container) { if (typeof object === 'string') { - const childNode = parseKey(object); + const childNode = KeyParser.parse(object); node.children = node.children || []; node.children.push(childNode); continue; @@ -47,7 +46,7 @@ function populateNode(node: AriaTemplateRoleNode, container: any[]) { continue; } - const childNode = parseKey(key); + const childNode = KeyParser.parse(key); if (childNode.kind === 'text') { node.children.push({ kind: 'text', @@ -106,47 +105,6 @@ function applyAttribute(node: AriaTemplateRoleNode, key: string, value: string) throw new Error(`Unsupported attribute [${key}] `); } -function parseKey(key: string): AriaTemplateNode { - const tokenRegex = /\s*([a-z]+|"(?:[^"]*)"|\/(?:[^\/]*)\/|\[.*?\])/g; - let match; - const tokens = []; - while ((match = tokenRegex.exec(key)) !== null) - tokens.push(match[1]); - - if (tokens.length === 0) - throw new Error(`Invalid key ${key}`); - - const role = tokens[0] as AriaRole; - - let name: string | RegExp = ''; - let index = 1; - if (tokens.length > 1 && (tokens[1].startsWith('"') || tokens[1].startsWith('/'))) { - const nameToken = tokens[1]; - if (nameToken.startsWith('"')) { - name = nameToken.slice(1, -1); - } else { - const pattern = nameToken.slice(1, -1); - name = new RegExp(pattern); - } - index = 2; - } - - const result: AriaTemplateRoleNode = { kind: 'role', role, name }; - for (; index < tokens.length; index++) { - const attrToken = tokens[index]; - if (attrToken.startsWith('[') && attrToken.endsWith(']')) { - const attrContent = attrToken.slice(1, -1).trim(); - const [attrName, attrValue] = attrContent.split('=', 2); - const value = attrValue !== undefined ? attrValue.trim() : 'true'; - applyAttribute(result, attrName, value); - } else { - throw new Error(`Invalid attribute token ${attrToken} in key ${key}`); - } - } - - return result; -} - function normalizeWhitespace(text: string) { return text.replace(/[\r\n\s\t]+/g, ' ').trim(); } @@ -154,3 +112,148 @@ function normalizeWhitespace(text: string) { function valueOrRegex(value: string): string | RegExp { return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value); } + +export class KeyParser { + private _input: string; + private _pos: number; + private _length: number; + + static parse(input: string): AriaTemplateNode { + return new KeyParser(input)._parse(); + } + + constructor(input: string) { + this._input = input; + this._pos = 0; + this._length = input.length; + } + + private _peek() { + return this._input[this._pos] || ''; + } + + private _next() { + if (this._pos < this._length) + return this._input[this._pos++]; + return null; + } + + private _eof() { + return this._pos >= this._length; + } + + private _skipWhitespace() { + while (!this._eof() && /\s/.test(this._peek())) + this._pos++; + } + + private _readIdentifier(): string { + if (this._eof()) + throw new Error('Unexpected end of input when expecting identifier'); + const start = this._pos; + while (!this._eof() && /[a-zA-Z]/.test(this._peek())) + this._pos++; + return this._input.slice(start, this._pos); + } + + private _readString(): string { + let result = ''; + let escaped = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === '\\') { + escaped = true; + result += ch; + } else if (ch === '"') { + return result; + } else { + result += ch; + } + } + throw new Error('Unterminated string starting at position ' + this._pos); + } + + private _readRegex(): string { + let result = ''; + let escaped = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === '\\') { + escaped = true; + result += ch; + } else if (ch === '/') { + return result; + } else { + result += ch; + } + } + throw new Error('Unterminated regex starting at position ' + this._pos); + } + + private _readStringOrRegex(): string | RegExp | null { + const ch = this._peek(); + if (ch === '"') { + this._next(); + return this._readString(); + } + + if (ch === '/') { + this._next(); + return new RegExp(this._readRegex()); + } + + return null; + } + + private _readFlags(): Map { + const flags = new Map(); + while (true) { + this._skipWhitespace(); + if (this._peek() === '[') { + this._next(); + this._skipWhitespace(); + const flagName = this._readIdentifier(); + this._skipWhitespace(); + let flagValue = ''; + if (this._peek() === '=') { + this._next(); + this._skipWhitespace(); + while (this._peek() !== ']' && !this._eof()) + flagValue += this._next(); + } + this._skipWhitespace(); + if (this._peek() !== ']') + throw new Error('Expected ] at position ' + this._pos); + + this._next(); // Consume ']' + flags.set(flagName, flagValue || 'true'); + } else { + break; + } + } + return flags; + } + + _parse(): AriaTemplateNode { + this._skipWhitespace(); + + const role = this._readIdentifier() as AriaTemplateRoleNode['role']; + this._skipWhitespace(); + const name = this._readStringOrRegex() || ''; + const result: AriaTemplateRoleNode = { kind: 'role', role, name }; + const flags = this._readFlags(); + for (const [name, value] of flags) + applyAttribute(result, name, value); + this._skipWhitespace(); + if (!this._eof()) + throw new Error('Unexpected input at position ' + this._pos); + + return result; + } +} diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index f63dbb7c37..7a4b637a06 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -275,7 +275,9 @@ ${body} } export function quoteMultiline(text: string, indent = ' ') { - const escape = (text: string) => text.replace(/`/g, '\\`').replace(/\\/g, '\\\\'); + const escape = (text: string) => text.replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${'); const lines = text.split('\n'); if (lines.length === 1) return '`' + escape(text) + '`'; diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index b2a0de91e2..2d9f635410 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -18,7 +18,7 @@ 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'; +import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded, yamlQuoteFragment } from './yaml'; type AriaProps = { checked?: boolean | 'mixed'; @@ -317,13 +317,13 @@ export function renderAriaTree(ariaNode: AriaNode, options?: { mode?: 'raw' | 'r if (ariaNode.selected === true) key += ` [selected]`; - const escapedKey = indent + '- ' + yamlEscapeStringIfNeeded(key, '\''); + const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key); if (!ariaNode.children.length) { lines.push(escapedKey); } else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string') { const text = includeText(ariaNode, ariaNode.children[0]) ? renderString(ariaNode.children[0] as string) : null; if (text) - lines.push(escapedKey + ': ' + yamlEscapeStringIfNeeded(text, '"')); + lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(text)); else lines.push(escapedKey); } else { diff --git a/packages/playwright-core/src/server/injected/yaml.ts b/packages/playwright-core/src/server/injected/yaml.ts index 0030ad1a26..97bf3a070d 100644 --- a/packages/playwright-core/src/server/injected/yaml.ts +++ b/packages/playwright-core/src/server/injected/yaml.ts @@ -14,21 +14,21 @@ * limitations under the License. */ -export function yamlEscapeStringIfNeeded(str: string, quote = '"'): string { +export function yamlEscapeKeyIfNeeded(str: string): string { if (!yamlStringNeedsQuotes(str)) return str; - return yamlEscapeString(str, quote); + return `'` + str.replace(/'/g, `''`) + `'`; } -export function yamlEscapeString(str: string, quote = '"'): string { - return quote + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => { +export function yamlEscapeValueIfNeeded(str: string): string { + if (!yamlStringNeedsQuotes(str)) + return str; + return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => { switch (c) { case '\\': return '\\\\'; case '"': - return quote === '"' ? '\\"' : '"'; - case '\'': - return quote === '\'' ? '\\\'' : '\''; + return '\\"'; case '\b': return '\\b'; case '\f': @@ -43,7 +43,7 @@ export function yamlEscapeString(str: string, quote = '"'): string { const code = c.charCodeAt(0); return '\\x' + code.toString(16).padStart(2, '0'); } - }) + quote; + }) + '"'; } export function yamlQuoteFragment(str: string, quote = '"'): string { diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index 37db89fcc6..ed81c9a033 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -27,6 +27,13 @@ export function escapeWithQuotes(text: string, char: string = '\'') { throw new Error('Invalid escape char'); } +export function escapeTemplateString(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${'); +} + export function isString(obj: any): obj is string { return typeof obj === 'string' || obj instanceof String; } diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index b21677da5a..a475ec39d8 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -24,6 +24,7 @@ import { callLogText } from '../util'; import { printReceivedStringContainExpectedSubstring } from './expect'; import { currentTestInfo } from '../common/globals'; import type { MatcherReceived } from '@injected/ariaSnapshot'; +import { escapeTemplateString } from 'playwright-core/lib/utils'; export async function toMatchAriaSnapshot( this: ExpectMatcherState, @@ -102,7 +103,7 @@ export async function toMatchAriaSnapshot( if (!this.isNot && pass === this.isNot && generateNewBaseline) { // Only rebaseline failed snapshots. - const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${indent(typedReceived.regex, '${indent} ')}\n\${indent}\`)`; + const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\`)`; return { pass: this.isNot, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline }; } @@ -118,7 +119,7 @@ export async function toMatchAriaSnapshot( } function escapePrivateUsePoints(str: string) { - return str.replace(/[\uE000-\uF8FF]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`); + return escapeTemplateString(str).replace(/[\uE000-\uF8FF]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`); } function unshift(snapshot: string): string { diff --git a/packages/playwright/src/runner/rebase.ts b/packages/playwright/src/runner/rebase.ts index c75a346550..7558ea0800 100644 --- a/packages/playwright/src/runner/rebase.ts +++ b/packages/playwright/src/runner/rebase.ts @@ -81,7 +81,7 @@ export async function applySuggestedRebaselines(config: FullConfigInternal) { if (matcher.loc!.start.column + 1 !== replacement.location.column) continue; const indent = lines[matcher.loc!.start.line - 1].match(/^\s*/)![0]; - const newText = replacement.code.replace(/\$\{indent\}/g, indent); + const newText = replacement.code.replace(/\{indent\}/g, indent); ranges.push({ start: matcher.start!, end: node.end!, oldText: source.substring(matcher.start!, node.end!), newText }); } } diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 8881fab972..435508dab9 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -386,8 +386,7 @@ it('should include pseudo codepoints', async ({ page, server }) => { `); }); -it('check aria-hidden text', async ({ page, server }) => { - await page.goto(server.EMPTY_PAGE); +it('check aria-hidden text', async ({ page }) => { await page.setContent(`

hello @@ -400,8 +399,7 @@ it('check aria-hidden text', async ({ page, server }) => { `); }); -it('should ignore presentation and none roles', async ({ page, server }) => { - await page.goto(server.EMPTY_PAGE); +it('should ignore presentation and none roles', async ({ page }) => { await page.setContent(`

  • hello
  • diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index e9f79c09e3..8c8b11525e 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -405,3 +405,56 @@ Locator: locator('body') + - heading "todos" [level=1] + - textbox "What needs to be done?"`); }); + +test('should unpack escaped names', async ({ page }) => { + { + await page.setContent(` + + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - 'button "Click: me"' + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - 'button /Click: me/' + `); + } + + { + await page.setContent(` + + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button "Click / me" + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button /Click \\/ me/ + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - 'button /Click \\/ me/' + `); + } + + { + await page.setContent(` + + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button "Click \\ me" + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button /Click \\\\ me/ + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - 'button /Click \\\\ me/' + `); + } + + { + await page.setContent(` + + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - 'button "Click '' me"' + `); + } +}); diff --git a/tests/playwright-test/update-aria-snapshot.spec.ts b/tests/playwright-test/update-aria-snapshot.spec.ts index 5bc1d28544..3c68788d4f 100644 --- a/tests/playwright-test/update-aria-snapshot.spec.ts +++ b/tests/playwright-test/update-aria-snapshot.spec.ts @@ -114,14 +114,14 @@ test('should generate baseline with regex', async ({ runInlineTest }, testInfo) + - list: + - listitem: Item 1 + - listitem: Item 2 -+ - listitem: /Time \\d+:\\d+/ -+ - listitem: /Year \\d+/ -+ - listitem: /Duration \\d+[hmsp]+/ -+ - listitem: /\\d+,\\d+/ -+ - listitem: /\\d+,\\d+\\.\\d+/ -+ - listitem: /Total \\d+/ ++ - listitem: /Time \\\\d+:\\\\d+/ ++ - listitem: /Year \\\\d+/ ++ - listitem: /Duration \\\\d+[hmsp]+/ ++ - listitem: /\\\\d+,\\\\d+/ ++ - listitem: /\\\\d+,\\\\d+\\\\.\\\\d+/ ++ - listitem: /Total \\\\d+/ + - listitem: /Regex 1/ -+ - listitem: /\\/Regex \\d+[hmsp]+\\// ++ - listitem: /\\\\/Regex \\\\d+[hmsp]+\\\\// + \`); }); @@ -136,6 +136,8 @@ test('should generate baseline with special characters', async ({ runInlineTest await page.setContent(\`
      + +
    • Item: 1
    • Item {a: b}
    \`); @@ -149,7 +151,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 -@@ -7,6 +7,12 @@ +@@ -9,6 +9,14 @@
  • Item: 1
  • Item {a: b}
\`); @@ -158,6 +160,8 @@ test('should generate baseline with special characters', async ({ runInlineTest + - list: + - 'button "Click: me"' + - 'button /Click: \\\\d+/' ++ - button "Click ' me" ++ - 'button "Click: '' me"' + - listitem: \"Item: 1\" + - listitem: \"Item {a: b}\" + \`);