Skip to content

Commit e966630

Browse files
committed
chore: bring some clarity to aria snapshot options
1 parent 63d898d commit e966630

File tree

13 files changed

+115
-70
lines changed

13 files changed

+115
-70
lines changed

packages/injected/src/ariaSnapshot.ts

Lines changed: 80 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,47 @@ type AriaRef = {
4848

4949
let lastRef = 0;
5050

51-
export type AriaTreeOptions = { forAI?: boolean, refPrefix?: string, refs?: boolean, visibleOnly?: boolean };
51+
export type AriaTreeOptions = {
52+
mode: 'ai' | 'expect' | 'codegen' | 'autoexpect';
53+
refPrefix?: string;
54+
};
55+
56+
type InternalOptions = {
57+
visibility: 'aria' | 'ariaOrVisible' | 'ariaAndVisible',
58+
refs: 'all' | 'interactable' | 'none',
59+
refPrefix?: string,
60+
includeGenericRole?: boolean,
61+
renderCursorPointer?: boolean,
62+
renderActive?: boolean,
63+
renderStringsAsRegex?: boolean,
64+
};
5265

53-
export function generateAriaTree(rootElement: Element, options?: AriaTreeOptions): AriaSnapshot {
66+
function toInternalOptions(options: AriaTreeOptions): InternalOptions {
67+
if (options.mode === 'ai') {
68+
// For AI consumption.
69+
return {
70+
visibility: 'ariaOrVisible',
71+
refs: 'interactable',
72+
refPrefix: options.refPrefix,
73+
includeGenericRole: true,
74+
renderActive: true,
75+
renderCursorPointer: true,
76+
};
77+
}
78+
if (options.mode === 'autoexpect') {
79+
// To auto-generate assertions on visible elements.
80+
return { visibility: 'ariaAndVisible', refs: 'all' };
81+
}
82+
if (options.mode === 'codegen') {
83+
// To generate aria assertion with regex heurisitcs.
84+
return { visibility: 'aria', refs: 'none', renderStringsAsRegex: true };
85+
}
86+
// To match aria snapshot.
87+
return { visibility: 'aria', refs: 'none' };
88+
}
89+
90+
export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOptions): AriaSnapshot {
91+
const options = toInternalOptions(publicOptions);
5492
const visited = new Set<Node>();
5593

5694
const snapshot: AriaSnapshot = {
@@ -79,8 +117,16 @@ export function generateAriaTree(rootElement: Element, options?: AriaTreeOptions
79117
return;
80118

81119
const element = node as Element;
82-
const isElementHiddenForAria = roleUtils.isElementHiddenForAria(element);
83-
if (isElementHiddenForAria && !options?.forAI)
120+
const isElementVisibleForAria = !roleUtils.isElementHiddenForAria(element);
121+
let visible = isElementVisibleForAria;
122+
if (options.visibility === 'ariaOrVisible')
123+
visible = isElementVisibleForAria || isElementVisible(element);
124+
if (options.visibility === 'ariaAndVisible')
125+
visible = isElementVisibleForAria && isElementVisible(element);
126+
127+
// Optimization: if we only consider aria visibility, we can skip child elements because
128+
// they will not be visible for aria as well.
129+
if (options.visibility === 'aria' && !visible)
84130
return;
85131

86132
const ariaChildren: Element[] = [];
@@ -93,7 +139,6 @@ export function generateAriaTree(rootElement: Element, options?: AriaTreeOptions
93139
}
94140
}
95141

96-
const visible = options?.visibleOnly ? isElementVisible(element) : !isElementHiddenForAria || isElementVisible(element);
97142
const childAriaNode = visible ? toAriaNode(element, options) : null;
98143
if (childAriaNode) {
99144
if (childAriaNode.ref) {
@@ -157,36 +202,39 @@ export function generateAriaTree(rootElement: Element, options?: AriaTreeOptions
157202
return snapshot;
158203
}
159204

160-
function ariaRef(element: Element, role: string, name: string, options?: AriaTreeOptions): string | undefined {
161-
if (!options?.forAI && !options?.refs)
162-
return undefined;
205+
function computeAriaRef(ariaNode: AriaNode, options: InternalOptions) {
206+
if (options.refs === 'none')
207+
return;
208+
if (options.refs === 'interactable' && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents))
209+
return;
163210

164211
let ariaRef: AriaRef | undefined;
165-
ariaRef = (element as any)._ariaRef;
166-
if (!ariaRef || ariaRef.role !== role || ariaRef.name !== name) {
167-
ariaRef = { role, name, ref: (options?.refPrefix ?? '') + 'e' + (++lastRef) };
168-
(element as any)._ariaRef = ariaRef;
212+
ariaRef = (ariaNode.element as any)._ariaRef;
213+
if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) {
214+
ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: (options.refPrefix ?? '') + 'e' + (++lastRef) };
215+
(ariaNode.element as any)._ariaRef = ariaRef;
169216
}
170-
return ariaRef.ref;
217+
ariaNode.ref = ariaRef.ref;
171218
}
172219

173-
function toAriaNode(element: Element, options?: AriaTreeOptions): AriaNode | null {
220+
function toAriaNode(element: Element, options: InternalOptions): AriaNode | null {
174221
const active = element.ownerDocument.activeElement === element;
175222
if (element.nodeName === 'IFRAME') {
176-
return {
223+
const ariaNode: AriaNode = {
177224
role: 'iframe',
178225
name: '',
179-
ref: ariaRef(element, 'iframe', '', options),
180226
children: [],
181227
props: {},
182228
element,
183229
box: box(element),
184230
receivesPointerEvents: true,
185231
active
186232
};
233+
computeAriaRef(ariaNode, options);
234+
return ariaNode;
187235
}
188236

189-
const defaultRole = options?.forAI ? 'generic' : null;
237+
const defaultRole = options.includeGenericRole ? 'generic' : null;
190238
const role = roleUtils.getAriaRole(element) ?? defaultRole;
191239
if (!role || role === 'presentation' || role === 'none')
192240
return null;
@@ -197,14 +245,14 @@ function toAriaNode(element: Element, options?: AriaTreeOptions): AriaNode | nul
197245
const result: AriaNode = {
198246
role,
199247
name,
200-
ref: ariaRef(element, role, name, options),
201248
children: [],
202249
props: {},
203250
element,
204251
box: box(element),
205252
receivesPointerEvents,
206253
active
207254
};
255+
computeAriaRef(result, options);
208256

209257
if (roleUtils.kAriaCheckedRoles.includes(role))
210258
result.checked = roleUtils.getAriaChecked(element);
@@ -245,7 +293,7 @@ function normalizeGenericRoles(node: AriaNode) {
245293
}
246294

247295
// Only remove generic that encloses one element, logical grouping still makes sense, even if it is not ref-able.
248-
const removeSelf = node.role === 'generic' && result.length <= 1 && result.every(c => typeof c !== 'string' && receivesPointerEvents(c));
296+
const removeSelf = node.role === 'generic' && result.length <= 1 && result.every(c => typeof c !== 'string' && !!c.ref);
249297
if (removeSelf)
250298
return result;
251299
node.children = result;
@@ -308,20 +356,20 @@ export type MatcherReceived = {
308356
regex: string;
309357
};
310358

311-
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } {
312-
const snapshot = generateAriaTree(rootElement);
359+
export function matchesExpectAriaTemplate(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } {
360+
const snapshot = generateAriaTree(rootElement, { mode: 'expect' });
313361
const matches = matchesNodeDeep(snapshot.root, template, false, false);
314362
return {
315363
matches,
316364
received: {
317-
raw: renderAriaTree(snapshot, { mode: 'raw' }),
318-
regex: renderAriaTree(snapshot, { mode: 'regex' }),
365+
raw: renderAriaTree(snapshot, { mode: 'expect' }),
366+
regex: renderAriaTree(snapshot, { mode: 'codegen' }),
319367
}
320368
};
321369
}
322370

323-
export function getAllByAria(rootElement: Element, template: AriaTemplateNode): Element[] {
324-
const root = generateAriaTree(rootElement).root;
371+
export function getAllElementsMatchingExpectAriaTemplate(rootElement: Element, template: AriaTemplateNode): Element[] {
372+
const root = generateAriaTree(rootElement, { mode: 'expect' }).root;
325373
const matches = matchesNodeDeep(root, template, true, false);
326374
return matches.map(n => n.element);
327375
}
@@ -411,10 +459,11 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
411459
return results;
412460
}
413461

414-
export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'raw' | 'regex', forAI?: boolean, refs?: boolean }): string {
462+
export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions): string {
463+
const options = toInternalOptions(publicOptions);
415464
const lines: string[] = [];
416-
const includeText = options?.mode === 'regex' ? textContributesInfo : () => true;
417-
const renderString = options?.mode === 'regex' ? convertToBestGuessRegex : (str: string) => str;
465+
const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true;
466+
const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str;
418467
const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string) => {
419468
if (typeof ariaNode === 'string') {
420469
if (parentAriaNode && !includeText(parentAriaNode, ariaNode))
@@ -442,7 +491,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
442491
key += ` [disabled]`;
443492
if (ariaNode.expanded)
444493
key += ` [expanded]`;
445-
if (ariaNode.active && options?.forAI)
494+
if (ariaNode.active && options.renderActive)
446495
key += ` [active]`;
447496
if (ariaNode.level)
448497
key += ` [level=${ariaNode.level}]`;
@@ -453,10 +502,9 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
453502
if (ariaNode.selected === true)
454503
key += ` [selected]`;
455504

456-
const includeRef = (options?.forAI && receivesPointerEvents(ariaNode)) || options?.refs;
457-
if (includeRef && ariaNode.ref) {
505+
if (ariaNode.ref) {
458506
key += ` [ref=${ariaNode.ref}]`;
459-
if (hasPointerCursor(ariaNode))
507+
if (options.renderCursorPointer && hasPointerCursor(ariaNode))
460508
key += ' [cursor=pointer]';
461509
}
462510

@@ -548,10 +596,6 @@ function textContributesInfo(node: AriaNode, text: string): boolean {
548596
return filtered.trim().length / text.length > 0.1;
549597
}
550598

551-
function receivesPointerEvents(ariaNode: AriaNode): boolean {
552-
return ariaNode.box.visible && ariaNode.receivesPointerEvents;
553-
}
554-
555599
function hasPointerCursor(ariaNode: AriaNode): boolean {
556600
return ariaNode.box.style?.cursor === 'pointer';
557601
}

packages/injected/src/consoleApi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ export class ConsoleAPI {
9191
inspect: (selector: string) => this._inspect(selector),
9292
selector: (element: Element) => this._selector(element),
9393
generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language),
94-
ariaSnapshot: (element?: Element, options?: { forAI?: boolean }) => {
95-
return this._injectedScript.ariaSnapshot(element || this._injectedScript.document.body, options);
94+
ariaSnapshot: (element?: Element) => {
95+
return this._injectedScript.ariaSnapshot(element || this._injectedScript.document.body, { mode: 'expect' });
9696
},
9797
resume: () => this._resume(),
9898
...new Locator(this._injectedScript, ''),

packages/injected/src/injectedScript.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { asLocator } from '@isomorphic/locatorGenerators';
1919
import { parseAttributeSelector, parseSelector, stringifySelector, visitAllSelectorParts } from '@isomorphic/selectorParser';
2020
import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '@isomorphic/stringUtils';
2121

22-
import { generateAriaTree, getAllByAria, matchesAriaTree, renderAriaTree } from './ariaSnapshot';
22+
import { generateAriaTree, getAllElementsMatchingExpectAriaTemplate, matchesExpectAriaTemplate, renderAriaTree } from './ariaSnapshot';
2323
import { enclosingShadowRootOrDocument, isElementVisible, isInsideScope, parentElementOrShadowHost, setGlobalOptions } from './domUtils';
2424
import { Highlight } from './highlight';
2525
import { kLayoutSelectorNames, layoutSelectorScore } from './layoutSelectorUtils';
@@ -297,21 +297,21 @@ export class InjectedScript {
297297
return new Set<Element>(result.map(r => r.element));
298298
}
299299

300-
ariaSnapshot(node: Node, options?: { mode?: 'raw' | 'regex' } & AriaTreeOptions): string {
300+
ariaSnapshot(node: Node, options: AriaTreeOptions): string {
301301
if (node.nodeType !== Node.ELEMENT_NODE)
302302
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
303303
this._lastAriaSnapshot = generateAriaTree(node as Element, options);
304304
return renderAriaTree(this._lastAriaSnapshot, options);
305305
}
306306

307307
ariaSnapshotForRecorder(): { ariaSnapshot: string, refs: Map<Element, string> } {
308-
const tree = generateAriaTree(this.document.body, { forAI: true });
309-
const ariaSnapshot = renderAriaTree(tree, { forAI: true });
308+
const tree = generateAriaTree(this.document.body, { mode: 'ai' });
309+
const ariaSnapshot = renderAriaTree(tree, { mode: 'ai' });
310310
return { ariaSnapshot, refs: tree.refs };
311311
}
312312

313-
getAllByAria(document: Document, template: AriaTemplateNode): Element[] {
314-
return getAllByAria(document.documentElement, template);
313+
getAllElementsMatchingExpectAriaTemplate(document: Document, template: AriaTemplateNode): Element[] {
314+
return getAllElementsMatchingExpectAriaTemplate(document.documentElement, template);
315315
}
316316

317317
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
@@ -1469,7 +1469,7 @@ export class InjectedScript {
14691469

14701470
{
14711471
if (expression === 'to.match.aria') {
1472-
const result = matchesAriaTree(element, options.expectedValue);
1472+
const result = matchesExpectAriaTemplate(element, options.expectedValue);
14731473
return {
14741474
received: result.received,
14751475
matches: !!result.matches.length,

packages/injected/src/recorder/recorder.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ class RecordActionTool implements RecorderTool {
548548
private _captureAriaSnapshotForAction(action: actions.Action) {
549549
const documentElement = this._recorder.injectedScript.document.documentElement;
550550
if (documentElement)
551-
action.ariaSnapshot = this._recorder.injectedScript.ariaSnapshot(documentElement, { mode: 'raw', refs: true, visibleOnly: true });
551+
action.ariaSnapshot = this._recorder.injectedScript.ariaSnapshot(documentElement, { mode: 'autoexpect' });
552552
}
553553

554554
private _recordAction(action: actions.Action) {
@@ -951,7 +951,7 @@ class TextAssertionTool implements RecorderTool {
951951
name: 'assertSnapshot',
952952
selector: this._hoverHighlight.selector,
953953
signals: [],
954-
ariaSnapshot: this._recorder.injectedScript.ariaSnapshot(target, { mode: 'regex' }),
954+
ariaSnapshot: this._recorder.injectedScript.ariaSnapshot(target, { mode: 'codegen' }),
955955
};
956956
} else {
957957
const generated = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
@@ -1384,7 +1384,7 @@ export class Recorder {
13841384

13851385
const ariaTemplateJSON = JSON.stringify(state.ariaTemplate);
13861386
if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) {
1387-
const elements = state.ariaTemplate ? this.injectedScript.getAllByAria(this.document, state.ariaTemplate) : [];
1387+
const elements = state.ariaTemplate ? this.injectedScript.getAllElementsMatchingExpectAriaTemplate(this.document, state.ariaTemplate) : [];
13881388
if (elements.length) {
13891389
const color = elements.length > 1 ? HighlightColors.multiple : HighlightColors.single;
13901390
highlight = elements.map(element => ({ element, color }));
@@ -1591,7 +1591,7 @@ export class Recorder {
15911591
}
15921592

15931593
elementPicked(selector: string, model: HighlightModel) {
1594-
const ariaSnapshot = this.injectedScript.ariaSnapshot(model.elements[0]);
1594+
const ariaSnapshot = this.injectedScript.ariaSnapshot(model.elements[0], { mode: 'expect' });
15951595
void this._delegate.elementPicked?.({ selector, ariaSnapshot });
15961596
}
15971597
}

packages/playwright-core/src/protocol/validator.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1559,7 +1559,6 @@ scheme.FrameAddStyleTagResult = tObject({
15591559
});
15601560
scheme.FrameAriaSnapshotParams = tObject({
15611561
selector: tString,
1562-
forAI: tOptional(tBoolean),
15631562
timeout: tNumber,
15641563
});
15651564
scheme.FrameAriaSnapshotResult = tObject({

packages/playwright-core/src/server/dispatchers/frameDispatcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,6 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Br
273273
}
274274

275275
async ariaSnapshot(params: channels.FrameAriaSnapshotParams, progress: Progress): Promise<channels.FrameAriaSnapshotResult> {
276-
return { snapshot: await this._frame.ariaSnapshot(progress, params.selector, params) };
276+
return { snapshot: await this._frame.ariaSnapshot(progress, params.selector) };
277277
}
278278
}

packages/playwright-core/src/server/dom.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -750,8 +750,8 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
750750
return this._page.delegate.getBoundingBox(this);
751751
}
752752

753-
async ariaSnapshot(options?: { forAI?: boolean, refPrefix?: string }): Promise<string> {
754-
return await this.evaluateInUtility(([injected, element, options]) => injected.ariaSnapshot(element, options), options);
753+
async ariaSnapshot(): Promise<string> {
754+
return await this.evaluateInUtility(([injected, element]) => injected.ariaSnapshot(element, { mode: 'expect' }), {});
755755
}
756756

757757
async screenshot(progress: Progress, options: ScreenshotOptions): Promise<Buffer> {

packages/playwright-core/src/server/frames.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1341,8 +1341,8 @@ export class Frame extends SdkObject {
13411341
return progress.wait(timeout);
13421342
}
13431343

1344-
async ariaSnapshot(progress: Progress, selector: string, options: { forAI?: boolean }): Promise<string> {
1345-
return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => progress.race(handle.ariaSnapshot(options)));
1344+
async ariaSnapshot(progress: Progress, selector: string): Promise<string> {
1345+
return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => progress.race(handle.ariaSnapshot()));
13461346
}
13471347

13481348
async expect(progress: Progress, selector: string | undefined, options: FrameExpectParams, timeout?: number): Promise<ExpectResult> {

packages/playwright-core/src/server/page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1005,7 +1005,7 @@ async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frame
10051005
const node = injected.document.body;
10061006
if (!node)
10071007
return true;
1008-
return injected.ariaSnapshot(node, { forAI: true, refPrefix });
1008+
return injected.ariaSnapshot(node, { mode: 'ai', refPrefix });
10091009
}, frameOrdinal ? 'f' + frameOrdinal : ''));
10101010
if (snapshotOrRetry === true)
10111011
return continuePolling;

packages/protocol/src/channels.d.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2750,11 +2750,10 @@ export type FrameAddStyleTagResult = {
27502750
};
27512751
export type FrameAriaSnapshotParams = {
27522752
selector: string,
2753-
forAI?: boolean,
27542753
timeout: number,
27552754
};
27562755
export type FrameAriaSnapshotOptions = {
2757-
forAI?: boolean,
2756+
27582757
};
27592758
export type FrameAriaSnapshotResult = {
27602759
snapshot: string,

0 commit comments

Comments
 (0)