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

View File

@ -104,25 +104,35 @@ export function isElementStyleVisibilityVisible(element: Element, style?: CSSSty
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.
const style = getElementComputedStyle(element);
if (!style)
return true;
return { visible: true };
if (style.display === 'contents') {
// display:contents is not rendered itself, but its child nodes are.
for (let child = element.firstChild; child; child = child.nextSibling) {
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))
return true;
return { visible: true, style };
}
return false;
return { visible: false, style };
}
if (!isElementStyleVisibilityVisible(element, style))
return false;
return { style, visible: false };
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) {

View File

@ -731,6 +731,19 @@ it('ref mode can be used to stitch all frame snapshots', async ({ page, server }
`.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 }) => {
await page.setContent(`
<style>
@ -742,21 +755,36 @@ it('emit generic roles for nodes w/o roles', async ({ page }) => {
</style>
<div>
<label>
<span>
<input type="radio" value="Apple" checked="">
</span>
<span>Apple</span>
</label>
<label>
<span>
<input type="radio" value="Pear">
</span>
<span>Pear</span>
</label>
<label>
<span>
<input type="radio" value="Orange">
</span>
<span>Orange</span>
</label>
</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:
- generic: Apple
- generic: Pear
- generic: Orange`);
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`);
});