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
42 changes: 42 additions & 0 deletions docs/src/aria-snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,48 @@ Similarly, you can partially match children in lists or groups by omitting speci
Partial matches let you create flexible snapshot tests that verify essential page structure without enforcing
specific content or attributes.

### Strict matching

By default, a template containing the subset of children will be matched:

```html
<ul>
<li>Feature A</li>
<li>Feature B</li>
<li>Feature C</li>
</ul>
```

*aria snapshot for partial match*

```yaml
- list
- listitem: Feature B
```


The `/children` property can be used to control how child elements are matched:
- `contain` (default): Matches if all specified children are present in any order
- `equal`: Matches if the children exactly match the specified list in order
- `deep-equal`: Matches if the children exactly match the specified list in order, including nested children

```html
<ul>
<li>Feature A</li>
<li>Feature B</li>
<li>Feature C</li>
</ul>
```

*aria snapshot will fail due Feature C not being in the template*

```yaml
- list
- /children: equal
- listitem: Feature A
- listitem: Feature B
```

### Matching with regular expressions

Regular expressions allow flexible matching for elements with dynamic or variable text. Accessible names and text can
Expand Down
74 changes: 45 additions & 29 deletions packages/injected/src/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export type MatcherReceived = {

export function matchesAriaTree(builtins: Builtins, rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } {
const snapshot = generateAriaTree(builtins, rootElement, 0, false);
const matches = matchesNodeDeep(snapshot.root, template, false);
const matches = matchesNodeDeep(snapshot.root, template, false, false);
return {
matches,
received: {
Expand All @@ -248,49 +248,65 @@ export function matchesAriaTree(builtins: Builtins, rootElement: Element, templa

export function getAllByAria(builtins: Builtins, rootElement: Element, template: AriaTemplateNode): Element[] {
const root = generateAriaTree(builtins, rootElement, 0, false).root;
const matches = matchesNodeDeep(root, template, true);
const matches = matchesNodeDeep(root, template, true, false);
return matches.map(n => n.element);
}

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

if (node !== null && typeof node === 'object' && template.kind === 'role') {
if (template.role !== 'fragment' && template.role !== node.role)
return false;
if (template.checked !== undefined && template.checked !== node.checked)
return false;
if (template.disabled !== undefined && template.disabled !== node.disabled)
return false;
if (template.expanded !== undefined && template.expanded !== node.expanded)
return false;
if (template.level !== undefined && template.level !== node.level)
return false;
if (template.pressed !== undefined && template.pressed !== node.pressed)
return false;
if (template.selected !== undefined && template.selected !== node.selected)
return false;
if (!matchesName(node.name, template))
return false;
if (!matchesText(node.props.url, template.props?.url))
return false;
if (!containsList(node.children || [], template.children || [], depth))
if (node === null || typeof node !== 'object' || template.kind !== 'role')
return false;

if (template.role !== 'fragment' && template.role !== node.role)
return false;
if (template.checked !== undefined && template.checked !== node.checked)
return false;
if (template.disabled !== undefined && template.disabled !== node.disabled)
return false;
if (template.expanded !== undefined && template.expanded !== node.expanded)
return false;
if (template.level !== undefined && template.level !== node.level)
return false;
if (template.pressed !== undefined && template.pressed !== node.pressed)
return false;
if (template.selected !== undefined && template.selected !== node.selected)
return false;
if (!matchesName(node.name, template))
return false;
if (!matchesText(node.props.url, template.props?.url))
return false;

// Proceed based on the container mode.
if (template.containerMode === 'contain')
return containsList(node.children || [], template.children || []);
if (template.containerMode === 'equal')
return listEqual(node.children || [], template.children || [], false);
if (template.containerMode === 'deep-equal' || isDeepEqual)
return listEqual(node.children || [], template.children || [], true);
return containsList(node.children || [], template.children || []);
}

function listEqual(children: (AriaNode | string)[], template: AriaTemplateNode[], isDeepEqual: boolean): boolean {
if (template.length !== children.length)
return false;
for (let i = 0; i < template.length; ++i) {
if (!matchesNode(children[i], template[i], isDeepEqual))
return false;
return true;
}
return false;
return true;
}

function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], depth: number): boolean {
function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[]): boolean {
if (template.length > children.length)
return false;
const cc = children.slice();
const tt = template.slice();
for (const t of tt) {
let c = cc.shift();
while (c) {
if (matchesNode(c, t, depth + 1))
if (matchesNode(c, t, false))
break;
c = cc.shift();
}
Expand All @@ -300,10 +316,10 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod
return true;
}

function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean): AriaNode[] {
function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean): AriaNode[] {
const results: AriaNode[] = [];
const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => {
if (matchesNode(node, template, 0)) {
if (matchesNode(node, template, isDeepEqual)) {
const result = typeof node === 'string' ? parent : node;
if (result)
results.push(result);
Expand Down
15 changes: 15 additions & 0 deletions packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type AriaTemplateRoleNode = AriaProps & {
name?: AriaRegex | string;
children?: AriaTemplateNode[];
props?: Record<string, string | AriaRegex>;
containerMode?: 'contain' | 'equal' | 'deep-equal';
};

export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
Expand Down Expand Up @@ -152,6 +153,20 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml
continue;
}

// - /children: equal
if (key.value === '/children') {
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
if (!valueIsString || (value.value !== 'contain' && value.value !== 'equal' && value.value !== 'deep-equal')) {
errors.push({
message: 'Strict value should be "contain", "equal" or "deep-equal"',
range: convertRange(((entry.value as any).range || map.range)),
});
continue;
}
container.containerMode = value.value;
continue;
}

// - /url: "about:blank"
if (key.value.startsWith('/')) {
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
Expand Down
98 changes: 98 additions & 0 deletions tests/page/to-match-aria-snapshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,101 @@ test('should match url', async ({ page }) => {
- /url: /.*example.com/
`);
});

test('should detect unexpected children: equal', async ({ page }) => {
await page.setContent(`
<ul>
<li>One</li>
<li>Two</li>
<li>Three</li>
</ul>
`);

await expect(page.locator('body')).toMatchAriaSnapshot(`
- list:
- listitem: "One"
- listitem: "Three"
`);

const e = await expect(page.locator('body')).toMatchAriaSnapshot(`
- list:
- /children: equal
- listitem: "One"
- listitem: "Three"
`, { timeout: 1000 }).catch(e => e);

expect(e.message).toContain('Timed out 1000ms waiting');
expect(stripAnsi(e.message)).toContain('+ - listitem: Two');
});

test('should detect unexpected children: deep-equal', async ({ page }) => {
await page.setContent(`
<ul>
<li>
<ul>
<li>1.1</li>
<li>1.2</li>
</ul>
</li>
</ul>
`);

await expect(page.locator('body')).toMatchAriaSnapshot(`
- list:
- listitem:
- list:
- listitem: 1.1
`);

await expect(page.locator('body')).toMatchAriaSnapshot(`
- list:
- /children: equal
- listitem:
- list:
- listitem: 1.1
`);

const e = await expect(page.locator('body')).toMatchAriaSnapshot(`
- list:
- /children: deep-equal
- listitem:
- list:
- listitem: 1.1
`, { timeout: 1000 }).catch(e => e);

expect(e.message).toContain('Timed out 1000ms waiting');
expect(stripAnsi(e.message)).toContain('+ - listitem: \"1.2\"');
});

test('should allow restoring contain mode inside deep-equal', async ({ page }) => {
await page.setContent(`
<ul>
<li>
<ul>
<li>1.1</li>
<li>1.2</li>
</ul>
</li>
</ul>
`);

const e = await expect(page.locator('body')).toMatchAriaSnapshot(`
- list:
- /children: deep-equal
- listitem:
- list:
- listitem: 1.1
`, { timeout: 1000 }).catch(e => e);

expect(e.message).toContain('Timed out 1000ms waiting');
expect(stripAnsi(e.message)).toContain('+ - listitem: \"1.2\"');

await expect(page.locator('body')).toMatchAriaSnapshot(`
- list:
- /children: deep-equal
- listitem:
- list:
- /children: contain
- listitem: 1.1
`);
});
Loading