chore(aria): only emit actionable generic nodes (#35838)

This commit is contained in:
Pavel Feldman 2025-05-02 13:28:00 -07:00 committed by GitHub
parent 71eb3b9f0f
commit 79cbb15a4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 92 additions and 49 deletions

View File

@ -186,31 +186,25 @@ function toAriaNode(element: Element, options?: { emitGeneric?: boolean }): Aria
return result;
}
function normalizeGenericRoles(rootA11yNode: AriaNode) {
const visit = (ariaNode: AriaNode) => {
const newChildren: (AriaNode | string)[] = [];
for (const child of ariaNode.children) {
function normalizeGenericRoles(node: AriaNode) {
const normalizeChildren = (node: AriaNode) => {
const result: (AriaNode | string)[] = [];
for (const child of node.children || []) {
if (typeof child === 'string') {
newChildren.push(child);
result.push(child);
continue;
}
const isEmptyGeneric = child.role === 'generic' && child.children.length === 0;
const isSingleGenericChild = child.role === 'generic' && child.children.length === 1;
if (isSingleGenericChild) {
// Inline single child chains.
const newChild = child.children[0];
newChildren.push(newChild);
if (typeof newChild !== 'string')
visit(newChild);
} else if (!isEmptyGeneric) {
// Empty div
newChildren.push(child);
visit(child);
}
const normalized = normalizeChildren(child);
result.push(...normalized);
}
ariaNode.children = newChildren;
const removeSelf = node.role === 'generic' && result.every(c => typeof c !== 'string' && canRef(c));
if (removeSelf)
return result;
node.children = result;
return [node];
};
visit(rootA11yNode);
normalizeChildren(node);
}
function normalizeStringChildren(rootA11yNode: AriaNode) {
@ -408,7 +402,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
key += ` [pressed]`;
if (ariaNode.selected === true)
key += ` [selected]`;
if (options?.ref && ariaNode.box.visible && ariaNode.receivesPointerEvents) {
if (options?.ref && canRef(ariaNode)) {
const id = ariaSnapshot.ids.get(ariaNode.element);
if (id)
key += ` [ref=s${ariaSnapshot.generation}e${id}]`;
@ -501,3 +495,7 @@ function textContributesInfo(node: AriaNode, text: string): boolean {
filtered = filtered.replace(substr, '');
return filtered.trim().length / text.length > 0.1;
}
function canRef(ariaNode: AriaNode): boolean {
return ariaNode.box.visible && ariaNode.receivesPointerEvents;
}

View File

@ -16,7 +16,30 @@
*/
import type { Locator, FrameLocator, Page } from '@playwright/test';
import { test as it, expect } from './pageTest';
import { test as it, expect as baseExpect } from './pageTest';
const expect = baseExpect.extend({
toContainYaml(received: string, expected: string) {
const trimmed = expected.split('\n').filter(a => !!a.trim());
const maxPrefixLength = Math.min(...trimmed.map(line => line.match(/^\s*/)[0].length));
const trimmedExpected = trimmed.map(line => line.substring(maxPrefixLength)).join('\n');
try {
if (this.isNot)
expect(received).not.toContain(trimmedExpected);
else
expect(received).toContain(trimmedExpected);
return {
pass: !this.isNot,
message: () => '',
};
} catch (e) {
return {
pass: this.isNot,
message: () => e.message,
};
}
}
});
function unshift(snapshot: string): string {
const lines = snapshot.split('\n');
@ -720,15 +743,15 @@ it('ref mode can be used to stitch all frame snapshots', async ({ page, server }
return result.join('\n');
}
expect(await allFrameSnapshot(page)).toEqual(`
- iframe [ref=s1e3]:
- iframe [ref=s1e3]:
- text: Hi, I'm frame
- iframe [ref=s1e4]:
- text: Hi, I'm frame
- iframe [ref=s1e4]:
- text: Hi, I'm frame
`.trim());
expect(await allFrameSnapshot(page)).toContainYaml(`
- iframe [ref=s1e3]:
- iframe [ref=s1e3]:
- text: Hi, I'm frame
- iframe [ref=s1e4]:
- text: Hi, I'm frame
- iframe [ref=s1e4]:
- text: Hi, I'm frame
`);
});
it('should not generate refs for hidden elements', async ({ page }) => {
@ -739,9 +762,11 @@ it('should not generate refs for hidden elements', async ({ page }) => {
`);
const snapshot = await page.locator('body').ariaSnapshot({ ref: true });
expect(snapshot).toContain(`- button "One" [ref=s1e3]
- button "Two"
- button "Three" [ref=s1e5]`);
expect(snapshot).toContainYaml(`
- button "One" [ref=s1e3]
- button "Two"
- button "Three" [ref=s1e5]
`);
});
it('should not generate refs for elements with pointer-events:none', async ({ page }) => {
@ -768,11 +793,13 @@ it('should not generate refs for elements with pointer-events:none', async ({ pa
`);
const snapshot = await page.locator('body').ariaSnapshot({ ref: true });
expect(snapshot).toContain(`- button "no-ref"
- button "with-ref" [ref=s1e5]
- button "with-ref" [ref=s1e8]
- button "with-ref" [ref=s1e11]
- button "no-ref"`);
expect(snapshot).toContainYaml(`
- button "no-ref"
- button "with-ref" [ref=s1e5]
- button "with-ref" [ref=s1e8]
- button "with-ref" [ref=s1e11]
- button "no-ref"
`);
});
it('emit generic roles for nodes w/o roles', async ({ page }) => {
@ -808,14 +835,32 @@ it('emit generic roles for nodes w/o roles', async ({ page }) => {
const snapshot = await page.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
expect(snapshot).toContain(`- generic [ref=s1e3]:
- generic [ref=s1e4]:
- radio "Apple" [checked]
- text: Apple
- generic [ref=s1e8]:
- radio "Pear"
- text: Pear
- generic [ref=s1e12]:
- radio "Orange"
- text: Orange`);
expect(snapshot).toContainYaml(`
- generic [ref=s1e5]:
- radio "Apple" [checked]
- generic [ref=s1e7]: Apple
- generic [ref=s1e9]:
- radio "Pear"
- generic [ref=s1e11]: Pear
- generic [ref=s1e13]:
- radio "Orange"
- generic [ref=s1e15]: Orange
`);
});
it('should collapse generic nodes', async ({ page }) => {
await page.setContent(`
<div>
<div>
<div>
<button>Button</button>
</div>
</div>
</div>
`);
const snapshot = await page.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
expect(snapshot).toContainYaml(`
- button \"Button\" [ref=s1e6]
`);
});