chore: allow matching aria children strictly (#35481)

This commit is contained in:
Pavel Feldman 2025-04-03 17:58:20 -07:00 committed by GitHub
parent 3eab92d312
commit 26fa959a10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 202 additions and 31 deletions

View File

@ -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 Partial matches let you create flexible snapshot tests that verify essential page structure without enforcing
specific content or attributes. 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 ### Matching with regular expressions
Regular expressions allow flexible matching for elements with dynamic or variable text. Accessible names and text can Regular expressions allow flexible matching for elements with dynamic or variable text. Accessible names and text can

View File

@ -236,7 +236,7 @@ export type MatcherReceived = {
export function matchesAriaTree(builtins: Builtins, rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } { export function matchesAriaTree(builtins: Builtins, rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } {
const snapshot = generateAriaTree(builtins, rootElement, 0, false); const snapshot = generateAriaTree(builtins, rootElement, 0, false);
const matches = matchesNodeDeep(snapshot.root, template, false); const matches = matchesNodeDeep(snapshot.root, template, false, false);
return { return {
matches, matches,
received: { received: {
@ -248,41 +248,57 @@ export function matchesAriaTree(builtins: Builtins, rootElement: Element, templa
export function getAllByAria(builtins: Builtins, rootElement: Element, template: AriaTemplateNode): Element[] { export function getAllByAria(builtins: Builtins, rootElement: Element, template: AriaTemplateNode): Element[] {
const root = generateAriaTree(builtins, rootElement, 0, false).root; 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); 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') if (typeof node === 'string' && template.kind === 'text')
return matchesTextNode(node, template); return matchesTextNode(node, template);
if (node !== null && typeof node === 'object' && template.kind === 'role') { if (node === null || typeof node !== 'object' || template.kind !== 'role')
if (template.role !== 'fragment' && template.role !== node.role) return false;
return false;
if (template.checked !== undefined && template.checked !== node.checked) if (template.role !== 'fragment' && template.role !== node.role)
return false; return false;
if (template.disabled !== undefined && template.disabled !== node.disabled) if (template.checked !== undefined && template.checked !== node.checked)
return false; return false;
if (template.expanded !== undefined && template.expanded !== node.expanded) if (template.disabled !== undefined && template.disabled !== node.disabled)
return false; return false;
if (template.level !== undefined && template.level !== node.level) if (template.expanded !== undefined && template.expanded !== node.expanded)
return false; return false;
if (template.pressed !== undefined && template.pressed !== node.pressed) if (template.level !== undefined && template.level !== node.level)
return false; return false;
if (template.selected !== undefined && template.selected !== node.selected) if (template.pressed !== undefined && template.pressed !== node.pressed)
return false; return false;
if (!matchesName(node.name, template)) if (template.selected !== undefined && template.selected !== node.selected)
return false; return false;
if (!matchesText(node.props.url, template.props?.url)) if (!matchesName(node.name, template))
return false; return false;
if (!containsList(node.children || [], template.children || [], depth)) if (!matchesText(node.props.url, template.props?.url))
return false; return false;
return true;
} // Proceed based on the container mode.
return false; 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) if (template.length > children.length)
return false; return false;
const cc = children.slice(); const cc = children.slice();
@ -290,7 +306,7 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod
for (const t of tt) { for (const t of tt) {
let c = cc.shift(); let c = cc.shift();
while (c) { while (c) {
if (matchesNode(c, t, depth + 1)) if (matchesNode(c, t, false))
break; break;
c = cc.shift(); c = cc.shift();
} }
@ -300,10 +316,10 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod
return true; 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 results: AriaNode[] = [];
const visit = (node: AriaNode | string, parent: AriaNode | null): boolean => { 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; const result = typeof node === 'string' ? parent : node;
if (result) if (result)
results.push(result); results.push(result);

View File

@ -47,6 +47,7 @@ export type AriaTemplateRoleNode = AriaProps & {
name?: AriaRegex | string; name?: AriaRegex | string;
children?: AriaTemplateNode[]; children?: AriaTemplateNode[];
props?: Record<string, string | AriaRegex>; props?: Record<string, string | AriaRegex>;
containerMode?: 'contain' | 'equal' | 'deep-equal';
}; };
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode; export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
@ -152,6 +153,20 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml
continue; 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" // - /url: "about:blank"
if (key.value.startsWith('/')) { if (key.value.startsWith('/')) {
const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string'; const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string';

View File

@ -693,3 +693,101 @@ test('should match url', async ({ page }) => {
- /url: /.*example.com/ - /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
`);
});