diff --git a/docs/src/aria-snapshots.md b/docs/src/aria-snapshots.md
index 1f6c01ab5d..4ace92d764 100644
--- a/docs/src/aria-snapshots.md
+++ b/docs/src/aria-snapshots.md
@@ -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
+
+ - Feature A
+ - Feature B
+ - Feature C
+
+```
+
+*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
+
+ - Feature A
+ - Feature B
+ - Feature C
+
+```
+
+*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
diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts
index 892b017d2f..98d482604e 100644
--- a/packages/injected/src/ariaSnapshot.ts
+++ b/packages/injected/src/ariaSnapshot.ts
@@ -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: {
@@ -248,41 +248,57 @@ 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))
- return false;
- return true;
- }
- return false;
+ 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 containsList(children: (AriaNode | string)[], template: AriaTemplateNode[], depth: number): boolean {
+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;
+}
+
+function containsList(children: (AriaNode | string)[], template: AriaTemplateNode[]): boolean {
if (template.length > children.length)
return false;
const cc = children.slice();
@@ -290,7 +306,7 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod
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();
}
@@ -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);
diff --git a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts
index 04e5b7c471..ffbd50ddef 100644
--- a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts
+++ b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts
@@ -47,6 +47,7 @@ export type AriaTemplateRoleNode = AriaProps & {
name?: AriaRegex | string;
children?: AriaTemplateNode[];
props?: Record;
+ containerMode?: 'contain' | 'equal' | 'deep-equal';
};
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
@@ -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';
diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts
index f57a01cab4..f929f1b11a 100644
--- a/tests/page/to-match-aria-snapshot.spec.ts
+++ b/tests/page/to-match-aria-snapshot.spec.ts
@@ -693,3 +693,101 @@ test('should match url', async ({ page }) => {
- /url: /.*example.com/
`);
});
+
+test('should detect unexpected children: equal', async ({ page }) => {
+ await page.setContent(`
+
+ `);
+
+ 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(`
+
+ `);
+
+ 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(`
+
+ `);
+
+ 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
+ `);
+});