chore: more yaml escaping tests (#33387)
This commit is contained in:
parent
dcf85edcb7
commit
135ed28740
|
@ -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 }) => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) + '`';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"'
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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}\"
|
||||
+ \`);
|
||||
|
|
Loading…
Reference in New Issue