chore(aria): do not generate refs for invisible elements (#35694)

This commit is contained in:
Pavel Feldman 2025-04-22 14:23:17 -07:00 committed by GitHub
parent 8e8f8635f2
commit 85eeb37c05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 57 additions and 17 deletions

View File

@ -17,17 +17,19 @@
import { Map, Set } from '@isomorphic/builtins'; import { Map, Set } from '@isomorphic/builtins';
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils'; import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
import { getElementComputedStyle, getGlobalOptions } from './domUtils'; import { box, getElementComputedStyle, getGlobalOptions } from './domUtils';
import * as roleUtils from './roleUtils'; import * as roleUtils from './roleUtils';
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml'; import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
import type { AriaProps, AriaRegex, AriaRole, AriaTemplateNode, AriaTemplateRoleNode, AriaTemplateTextNode } from '@isomorphic/ariaSnapshot'; import type { AriaProps, AriaRegex, AriaRole, AriaTemplateNode, AriaTemplateRoleNode, AriaTemplateTextNode } from '@isomorphic/ariaSnapshot';
import type { Box } from './domUtils';
export type AriaNode = AriaProps & { export type AriaNode = AriaProps & {
role: AriaRole | 'fragment' | 'iframe'; role: AriaRole | 'fragment' | 'iframe';
name: string; name: string;
children: (AriaNode | string)[]; children: (AriaNode | string)[];
element: Element; element: Element;
box: Box;
props: Record<string, string>; props: Record<string, string>;
}; };
@ -42,7 +44,7 @@ export function generateAriaTree(rootElement: Element, generation: number, optio
const visited = new Set<Node>(); const visited = new Set<Node>();
const snapshot: AriaSnapshot = { const snapshot: AriaSnapshot = {
root: { role: 'fragment', name: '', children: [], element: rootElement, props: {} }, root: { role: 'fragment', name: '', children: [], element: rootElement, props: {}, box: box(rootElement) },
elements: new Map<number, Element>(), elements: new Map<number, Element>(),
generation, generation,
ids: new Map<Element, number>(), ids: new Map<Element, number>(),
@ -147,7 +149,7 @@ export function generateAriaTree(rootElement: Element, generation: number, optio
function toAriaNode(element: Element, options?: { emitGeneric?: boolean }): AriaNode | null { function toAriaNode(element: Element, options?: { emitGeneric?: boolean }): AriaNode | null {
if (element.nodeName === 'IFRAME') if (element.nodeName === 'IFRAME')
return { role: 'iframe', name: '', children: [], props: {}, element }; return { role: 'iframe', name: '', children: [], props: {}, element, box: box(element) };
const defaultRole = options?.emitGeneric ? 'generic' : null; const defaultRole = options?.emitGeneric ? 'generic' : null;
const role = roleUtils.getAriaRole(element) ?? defaultRole; const role = roleUtils.getAriaRole(element) ?? defaultRole;
@ -155,7 +157,7 @@ function toAriaNode(element: Element, options?: { emitGeneric?: boolean }): Aria
return null; return null;
const name = normalizeWhiteSpace(roleUtils.getElementAccessibleName(element, false) || ''); const name = normalizeWhiteSpace(roleUtils.getElementAccessibleName(element, false) || '');
const result: AriaNode = { role, name, children: [], props: {}, element }; const result: AriaNode = { role, name, children: [], props: {}, element, box: box(element) };
if (roleUtils.kAriaCheckedRoles.includes(role)) if (roleUtils.kAriaCheckedRoles.includes(role))
result.checked = roleUtils.getAriaChecked(element); result.checked = roleUtils.getAriaChecked(element);
@ -405,7 +407,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
key += ` [pressed]`; key += ` [pressed]`;
if (ariaNode.selected === true) if (ariaNode.selected === true)
key += ` [selected]`; key += ` [selected]`;
if (options?.ref) { if (options?.ref && ariaNode.box.visible) {
const id = ariaSnapshot.ids.get(ariaNode.element); const id = ariaSnapshot.ids.get(ariaNode.element);
if (id) if (id)
key += ` [ref=s${ariaSnapshot.generation}e${id}]`; key += ` [ref=s${ariaSnapshot.generation}e${id}]`;

View File

@ -104,25 +104,35 @@ export function isElementStyleVisibilityVisible(element: Element, style?: CSSSty
return true; return true;
} }
export function isElementVisible(element: Element): boolean { export type Box = {
visible: boolean;
rect?: DOMRect;
style?: CSSStyleDeclaration;
};
export function box(element: Element): Box {
// Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises. // Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises.
const style = getElementComputedStyle(element); const style = getElementComputedStyle(element);
if (!style) if (!style)
return true; return { visible: true };
if (style.display === 'contents') { if (style.display === 'contents') {
// display:contents is not rendered itself, but its child nodes are. // display:contents is not rendered itself, but its child nodes are.
for (let child = element.firstChild; child; child = child.nextSibling) { for (let child = element.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && isElementVisible(child as Element)) if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && isElementVisible(child as Element))
return true; return { visible: true, style };
if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text)) if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text))
return true; return { visible: true, style };
} }
return false; return { visible: false, style };
} }
if (!isElementStyleVisibilityVisible(element, style)) if (!isElementStyleVisibilityVisible(element, style))
return false; return { style, visible: false };
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0; return { rect, style, visible: rect.width > 0 && rect.height > 0 };
}
export function isElementVisible(element: Element): boolean {
return box(element).visible;
} }
export function isVisibleTextNode(node: Text) { export function isVisibleTextNode(node: Text) {

View File

@ -731,6 +731,19 @@ it('ref mode can be used to stitch all frame snapshots', async ({ page, server }
`.trim()); `.trim());
}); });
it('should not include hidden input elements', async ({ page }) => {
await page.setContent(`
<button>One</button>
<button style="width: 0; height: 0; appearance: none; border: 0; padding: 0;">Two</button>
<button>Three</button>
`);
const snapshot = await page.locator('body').ariaSnapshot({ ref: true });
expect(snapshot).toContain(`- button "One" [ref=s1e3]
- button "Two"
- button "Three" [ref=s1e5]`);
});
it('emit generic roles for nodes w/o roles', async ({ page }) => { it('emit generic roles for nodes w/o roles', async ({ page }) => {
await page.setContent(` await page.setContent(`
<style> <style>
@ -742,21 +755,36 @@ it('emit generic roles for nodes w/o roles', async ({ page }) => {
</style> </style>
<div> <div>
<label> <label>
<span>
<input type="radio" value="Apple" checked="">
</span>
<span>Apple</span> <span>Apple</span>
</label> </label>
<label> <label>
<span>
<input type="radio" value="Pear">
</span>
<span>Pear</span> <span>Pear</span>
</label> </label>
<label> <label>
<span>
<input type="radio" value="Orange">
</span>
<span>Orange</span> <span>Orange</span>
</label> </label>
</div> </div>
`); `);
const snapshot = await page.locator('body').ariaSnapshot({ emitGeneric: true }); const snapshot = await page.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
expect(snapshot).toContain(`- generic: expect(snapshot).toContain(`- generic [ref=s1e3]:
- generic: Apple - generic [ref=s1e4]:
- generic: Pear - radio "Apple" [checked]
- generic: Orange`); - text: Apple
- generic [ref=s1e8]:
- radio "Pear"
- text: Pear
- generic [ref=s1e12]:
- radio "Orange"
- text: Orange`);
}); });