Skip to content

Commit 485e780

Browse files
committed
chore: allow matching aria children strictly
1 parent 01ea1ca commit 485e780

File tree

4 files changed

+200
-29
lines changed

4 files changed

+200
-29
lines changed

docs/src/aria-snapshots.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,48 @@ Similarly, you can partially match children in lists or groups by omitting speci
263263
Partial matches let you create flexible snapshot tests that verify essential page structure without enforcing
264264
specific content or attributes.
265265

266+
### Strict matching
267+
268+
By default, a template containing the subset of children will be matched:
269+
270+
```html
271+
<ul>
272+
<li>Feature A</li>
273+
<li>Feature B</li>
274+
<li>Feature C</li>
275+
</ul>
276+
```
277+
278+
*aria snapshot for partial match*
279+
280+
```yaml
281+
- list
282+
- listitem: Feature B
283+
```
284+
285+
286+
The `/children` property can be used to control how child elements are matched:
287+
- `contain` (default): Matches if all specified children are present in any order
288+
- `equal`: Matches if the children exactly match the specified list in order
289+
- `deep-equal`: Matches if the children exactly match the specified list in order, including nested children
290+
291+
```html
292+
<ul>
293+
<li>Feature A</li>
294+
<li>Feature B</li>
295+
<li>Feature C</li>
296+
</ul>
297+
```
298+
299+
*aria snapshot will fail due Feature C not being in the template*
300+
301+
```yaml
302+
- list
303+
- /children: equal
304+
- listitem: Feature A
305+
- listitem: Feature B
306+
```
307+
266308
### Matching with regular expressions
267309

268310
Regular expressions allow flexible matching for elements with dynamic or variable text. Accessible names and text can

packages/playwright-core/src/server/injected/ariaSnapshot.ts

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ export type MatcherReceived = {
236236

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

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

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

259-
if (node !== null && typeof node === 'object' && template.kind === 'role') {
260-
if (template.role !== 'fragment' && template.role !== node.role)
261-
return false;
262-
if (template.checked !== undefined && template.checked !== node.checked)
263-
return false;
264-
if (template.disabled !== undefined && template.disabled !== node.disabled)
265-
return false;
266-
if (template.expanded !== undefined && template.expanded !== node.expanded)
267-
return false;
268-
if (template.level !== undefined && template.level !== node.level)
269-
return false;
270-
if (template.pressed !== undefined && template.pressed !== node.pressed)
271-
return false;
272-
if (template.selected !== undefined && template.selected !== node.selected)
273-
return false;
274-
if (!matchesName(node.name, template))
275-
return false;
276-
if (!matchesText(node.props.url, template.props?.url))
277-
return false;
278-
if (!containsList(node.children || [], template.children || [], depth))
259+
if (node === null || typeof node !== 'object' || template.kind !== 'role')
260+
return false;
261+
262+
if (template.role !== 'fragment' && template.role !== node.role)
263+
return false;
264+
if (template.checked !== undefined && template.checked !== node.checked)
265+
return false;
266+
if (template.disabled !== undefined && template.disabled !== node.disabled)
267+
return false;
268+
if (template.expanded !== undefined && template.expanded !== node.expanded)
269+
return false;
270+
if (template.level !== undefined && template.level !== node.level)
271+
return false;
272+
if (template.pressed !== undefined && template.pressed !== node.pressed)
273+
return false;
274+
if (template.selected !== undefined && template.selected !== node.selected)
275+
return false;
276+
if (!matchesName(node.name, template))
277+
return false;
278+
if (!matchesText(node.props.url, template.props?.url))
279+
return false;
280+
281+
// Proceed based on the container mode.
282+
if (template.containerMode === 'contain')
283+
return containsList(node.children || [], template.children || []);
284+
if (template.containerMode === 'equal')
285+
return listEqual(node.children || [], template.children || [], false);
286+
if (template.containerMode === 'deep-equal' || isDeepEqual)
287+
return listEqual(node.children || [], template.children || [], true);
288+
return containsList(node.children || [], template.children || []);
289+
}
290+
291+
function listEqual(children: (AriaNode | string)[], template: AriaTemplateNode[], isDeepEqual: boolean): boolean {
292+
if (template.length !== children.length)
293+
return false;
294+
for (let i = 0; i < template.length; ++i) {
295+
if (!matchesNode(children[i], template[i], isDeepEqual))
279296
return false;
280-
return true;
281297
}
282-
return false;
298+
return true;
283299
}
284300

285-
function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], depth: number): boolean {
301+
function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[]): boolean {
286302
if (template.length > children.length)
287303
return false;
288304
const cc = children.slice();
289305
const tt = template.slice();
290306
for (const t of tt) {
291307
let c = cc.shift();
292308
while (c) {
293-
if (matchesNode(c, t, depth + 1))
309+
if (matchesNode(c, t, false))
294310
break;
295311
c = cc.shift();
296312
}
@@ -300,10 +316,10 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod
300316
return true;
301317
}
302318

303-
function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean): AriaNode[] {
319+
function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: boolean, isDeepEqual: boolean): AriaNode[] {
304320
const results: AriaNode[] = [];
305321
const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => {
306-
if (matchesNode(node, template, 0)) {
322+
if (matchesNode(node, template, isDeepEqual)) {
307323
const result = typeof node === 'string' ? parent : node;
308324
if (result)
309325
results.push(result);

packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export type AriaTemplateRoleNode = AriaProps & {
4747
name?: AriaRegex | string;
4848
children?: AriaTemplateNode[];
4949
props?: Record<string, string | AriaRegex>;
50+
containerMode?: 'contain' | 'equal' | 'deep-equal';
5051
};
5152

5253
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
@@ -152,6 +153,20 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml
152153
continue;
153154
}
154155

156+
// - /children: equal
157+
if (key.value === '/children') {
158+
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';
159+
if (!valueIsString || (value.value !== 'contain' && value.value !== 'equal' && value.value !== 'deep-equal')) {
160+
errors.push({
161+
message: 'Strict value should be "contain", "equal" or "deep-equal"',
162+
range: convertRange(((entry.value as any).range || map.range)),
163+
});
164+
continue;
165+
}
166+
container.containerMode = value.value;
167+
continue;
168+
}
169+
155170
// - /url: "about:blank"
156171
if (key.value.startsWith('/')) {
157172
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';

tests/page/to-match-aria-snapshot.spec.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,3 +693,101 @@ test('should match url', async ({ page }) => {
693693
- /url: /.*example.com/
694694
`);
695695
});
696+
697+
test('should detect unexpected children: equal', async ({ page }) => {
698+
await page.setContent(`
699+
<ul>
700+
<li>One</li>
701+
<li>Two</li>
702+
<li>Three</li>
703+
</ul>
704+
`);
705+
706+
await expect(page.locator('body')).toMatchAriaSnapshot(`
707+
- list:
708+
- listitem: "One"
709+
- listitem: "Three"
710+
`);
711+
712+
const e = await expect(page.locator('body')).toMatchAriaSnapshot(`
713+
- list:
714+
- /children: equal
715+
- listitem: "One"
716+
- listitem: "Three"
717+
`, { timeout: 1000 }).catch(e => e);
718+
719+
expect(e.message).toContain('Timed out 1000ms waiting');
720+
expect(stripAnsi(e.message)).toContain('+ - listitem: Two');
721+
});
722+
723+
test('should detect unexpected children: deep-equal', async ({ page }) => {
724+
await page.setContent(`
725+
<ul>
726+
<li>
727+
<ul>
728+
<li>1.1</li>
729+
<li>1.2</li>
730+
</ul>
731+
</li>
732+
</ul>
733+
`);
734+
735+
await expect(page.locator('body')).toMatchAriaSnapshot(`
736+
- list:
737+
- listitem:
738+
- list:
739+
- listitem: 1.1
740+
`);
741+
742+
await expect(page.locator('body')).toMatchAriaSnapshot(`
743+
- list:
744+
- /children: equal
745+
- listitem:
746+
- list:
747+
- listitem: 1.1
748+
`);
749+
750+
const e = await expect(page.locator('body')).toMatchAriaSnapshot(`
751+
- list:
752+
- /children: deep-equal
753+
- listitem:
754+
- list:
755+
- listitem: 1.1
756+
`, { timeout: 1000 }).catch(e => e);
757+
758+
expect(e.message).toContain('Timed out 1000ms waiting');
759+
expect(stripAnsi(e.message)).toContain('+ - listitem: \"1.2\"');
760+
});
761+
762+
test('should allow restoring contain mode inside deep-equal', async ({ page }) => {
763+
await page.setContent(`
764+
<ul>
765+
<li>
766+
<ul>
767+
<li>1.1</li>
768+
<li>1.2</li>
769+
</ul>
770+
</li>
771+
</ul>
772+
`);
773+
774+
const e = await expect(page.locator('body')).toMatchAriaSnapshot(`
775+
- list:
776+
- /children: deep-equal
777+
- listitem:
778+
- list:
779+
- listitem: 1.1
780+
`, { timeout: 1000 }).catch(e => e);
781+
782+
expect(e.message).toContain('Timed out 1000ms waiting');
783+
expect(stripAnsi(e.message)).toContain('+ - listitem: \"1.2\"');
784+
785+
await expect(page.locator('body')).toMatchAriaSnapshot(`
786+
- list:
787+
- /children: deep-equal
788+
- listitem:
789+
- list:
790+
- /children: contain
791+
- listitem: 1.1
792+
`);
793+
});

0 commit comments

Comments
 (0)