Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions packages/injected/src/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { computeBox, getElementComputedStyle, isElementVisible } from './domUtil
import * as roleUtils from './roleUtils';
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';

import type { AriaProps, AriaRegex, AriaRole, AriaTemplateNode, AriaTemplateRoleNode, AriaTemplateTextNode } from '@isomorphic/ariaSnapshot';
import type { AriaProps, AriaRegex, AriaTextValue, AriaRole, AriaTemplateNode } from '@isomorphic/ariaSnapshot';
import type { Box } from './domUtils';

export type AriaNode = AriaProps & {
Expand Down Expand Up @@ -342,7 +342,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
visit(rootA11yNode);
}

function matchesText(text: string, template: AriaRegex | string | undefined): boolean {
function matchesStringOrRegex(text: string, template: AriaRegex | string | undefined): boolean {
if (!template)
return true;
if (!text)
Expand All @@ -352,12 +352,39 @@ function matchesText(text: string, template: AriaRegex | string | undefined): bo
return !!text.match(new RegExp(template.pattern));
}

function matchesTextNode(text: string, template: AriaTemplateTextNode) {
return matchesText(text, template.text);
function matchesTextValue(text: string, template: AriaTextValue | undefined) {
if (!template?.normalized)
return true;
if (!text)
return false;
if (text === template.normalized)
return true;
// Accept pattern as value.
if (text === template.raw)
return true;

const regex = cachedRegex(template);
if (regex)
return !!text.match(regex);
return false;
}

function matchesName(text: string, template: AriaTemplateRoleNode) {
return matchesText(text, template.name);
const cachedRegexSymbol = Symbol('cachedRegex');

function cachedRegex(template: AriaTextValue): RegExp | null {
if ((template as any)[cachedRegexSymbol] !== undefined)
return (template as any)[cachedRegexSymbol];

const { raw } = template;
const canBeRegex = raw.startsWith('/') && raw.endsWith('/') && raw.length > 1;
let regex: RegExp | null;
try {
regex = canBeRegex ? new RegExp(raw.slice(1, -1)) : null;
} catch (e) {
regex = null;
}
(template as any)[cachedRegexSymbol] = regex;
return regex;
}

export type MatcherReceived = {
Expand Down Expand Up @@ -385,7 +412,7 @@ export function getAllElementsMatchingExpectAriaTemplate(rootElement: Element, t

function matchesNode(node: AriaNode | string, template: AriaTemplateNode, isDeepEqual: boolean): boolean {
if (typeof node === 'string' && template.kind === 'text')
return matchesTextNode(node, template);
return matchesTextValue(node, template.text);

if (node === null || typeof node !== 'object' || template.kind !== 'role')
return false;
Expand All @@ -404,9 +431,9 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode, isDeep
return false;
if (template.selected !== undefined && template.selected !== node.selected)
return false;
if (!matchesName(node.name, template))
if (!matchesStringOrRegex(node.name, template.name))
return false;
if (!matchesText(node.props.url, template.props?.url))
if (!matchesTextValue(node.props.url, template.props?.url))
return false;

// Proceed based on the container mode.
Expand Down
29 changes: 18 additions & 11 deletions packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,23 @@ export type AriaProps = {
// We pass parsed template between worlds using JSON, make it easy.
export type AriaRegex = { pattern: string };

// We can't tell apart pattern and text, so we pass both.
export type AriaTextValue = {
raw: string;
normalized: string;
};

export type AriaTemplateTextNode = {
kind: 'text';
text: AriaRegex | string;
text: AriaTextValue;
};

export type AriaTemplateRoleNode = AriaProps & {
kind: 'role';
role: AriaRole | 'fragment';
name?: AriaRegex | string;
children?: AriaTemplateNode[];
props?: Record<string, string | AriaRegex>;
props?: Record<string, AriaTextValue>;
containerMode?: 'contain' | 'equal' | 'deep-equal';
};

Expand Down Expand Up @@ -150,7 +156,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: Pars
}
container.children.push({
kind: 'text',
text: valueOrRegex(value.value)
text: textValue(value.value)
});
continue;
}
Expand Down Expand Up @@ -180,7 +186,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: Pars
continue;
}
container.props = container.props ?? {};
container.props[key.value.slice(1)] = valueOrRegex(value.value);
container.props[key.value.slice(1)] = textValue(value.value);
continue;
}

Expand All @@ -205,7 +211,7 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: Pars
...childNode,
children: [{
kind: 'text',
text: valueOrRegex(String(value.value))
text: textValue(String(value.value))
}]
});
continue;
Expand Down Expand Up @@ -258,19 +264,21 @@ function normalizeWhitespace(text: string) {
return text.replace(/[\u200b\u00ad]/g, '').replace(/[\r\n\s\t]+/g, ' ').trim();
}

export function valueOrRegex(value: string): string | AriaRegex {
return value.startsWith('/') && value.endsWith('/') && value.length > 1 ? { pattern: value.slice(1, -1) } : normalizeWhitespace(value);
export function textValue(value: string): AriaTextValue {
return {
raw: value,
normalized: normalizeWhitespace(value),
};
}

export class KeyParser {
private _input: string;
private _pos: number;
private _length: number;
private _options: ParsingOptions;

static parse(text: yamlTypes.Scalar<string>, options: ParsingOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null {
try {
return new KeyParser(text.value, options)._parse();
return new KeyParser(text.value)._parse();
} catch (e) {
if (e instanceof ParserError) {
const message = options.prettyErrors === false ? e.message : e.message + ':\n\n' + text.value + '\n' + ' '.repeat(e.pos) + '^\n';
Expand All @@ -284,11 +292,10 @@ export class KeyParser {
}
}

constructor(input: string, options: ParsingOptions) {
constructor(input: string) {
this._input = input;
this._pos = 0;
this._length = input.length;
this._options = options;
}

private _peek() {
Expand Down
8 changes: 8 additions & 0 deletions tests/page/page-aria-snapshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,3 +686,11 @@ it('should snapshot placeholder when different from the name', async ({ page })
- /placeholder: Placeholder
`);
});

it('match values both against regex and string', async ({ page }) => {
await page.setContent(`<a href="/auth?r=/">Log in</a>`);
await checkAndMatchSnapshot(page.locator('body'), `
- link "Log in":
- /url: /auth?r=/
`);
});
12 changes: 12 additions & 0 deletions tests/page/to-match-aria-snapshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,3 +849,15 @@ test('top-level deep-equal', { annotation: { type: 'issue', description: 'https:
+ - listitem: "1.2"
`.trim());
});


test('treat bad regex as a string', async ({ page }) => {
await page.setContent(`<a href="/foo">Log in</a>`);
const error = await expect(page.locator('body')).toMatchAriaSnapshot(`
- link "Log in":
- /url: /[a/
`, { timeout: 1 }).catch(e => e);
expect(stripAnsi(error.message)).toContain('expect(locator).toMatchAriaSnapshot(expected) failed');
expect(stripAnsi(error.message)).toContain('- - /url: /[a/');
expect(stripAnsi(error.message)).toContain('+ - /url: /foo');
});
Loading