chore: allow matching aria children strictly (#35481)
This commit is contained in:
parent
3eab92d312
commit
26fa959a10
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
@ -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';
|
||||
|
|
|
@ -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…
Reference in New Issue