chore: more yaml escaping tests (#33387)

This commit is contained in:
Pavel Feldman 2024-10-31 17:14:11 -07:00 committed by GitHub
parent dcf85edcb7
commit 135ed28740
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 246 additions and 71 deletions

View File

@ -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 }) => {

View File

@ -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<string, string> {
const flags = new Map<string, string>();
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;
}
}

View File

@ -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) + '`';

View File

@ -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 {

View File

@ -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 {

View File

@ -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;
}

View File

@ -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 {

View File

@ -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 });
}
}

View File

@ -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(`
<p>
<span>hello</span>
@ -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(`
<ul>
<li role='presentation'>hello</li>

View File

@ -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(`
<button>Click: me</button>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- 'button "Click: me"'
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- 'button /Click: me/'
`);
}
{
await page.setContent(`
<button>Click / me</button>
`);
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(`
<button>Click \\ me</button>
`);
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(`
<button>Click ' me</button>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- 'button "Click '' me"'
`);
}
});

View File

@ -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(\`<ul>
<button>Click: me</button>
<button>Click: 123</button>
<button>Click ' me</button>
<button>Click: ' me</button>
<li>Item: 1</li>
<li>Item {a: b}</li>
</ul>\`);
@ -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 @@
<li>Item: 1</li>
<li>Item {a: b}</li>
</ul>\`);
@ -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}\"
+ \`);