Skip to content

Commit 3196aff

Browse files
authored
chore: experiment with stable aria refs (#35900)
1 parent 7850c15 commit 3196aff

File tree

3 files changed

+109
-78
lines changed

3 files changed

+109
-78
lines changed

packages/injected/src/ariaSnapshot.ts

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type { Box } from './domUtils';
2626
export type AriaNode = AriaProps & {
2727
role: AriaRole | 'fragment' | 'iframe';
2828
name: string;
29+
ref?: number;
2930
children: (AriaNode | string)[];
3031
element: Element;
3132
box: Box;
@@ -36,28 +37,24 @@ export type AriaNode = AriaProps & {
3637
export type AriaSnapshot = {
3738
root: AriaNode;
3839
elements: Map<number, Element>;
39-
generation: number;
40-
ids: Map<Element, number>;
4140
};
4241

43-
export function generateAriaTree(rootElement: Element, generation: number, options?: { forAI?: boolean }): AriaSnapshot {
42+
type AriaRef = {
43+
role: string;
44+
name: string;
45+
ref: number;
46+
};
47+
48+
let lastRef = 0;
49+
50+
export function generateAriaTree(rootElement: Element, options?: { forAI?: boolean }): AriaSnapshot {
4451
const visited = new Set<Node>();
4552

4653
const snapshot: AriaSnapshot = {
4754
root: { role: 'fragment', name: '', children: [], element: rootElement, props: {}, box: box(rootElement), receivesPointerEvents: true },
4855
elements: new Map<number, Element>(),
49-
generation,
50-
ids: new Map<Element, number>(),
5156
};
5257

53-
const addElement = (element: Element) => {
54-
const id = snapshot.elements.size + 1;
55-
snapshot.elements.set(id, element);
56-
snapshot.ids.set(element, id);
57-
};
58-
59-
addElement(rootElement);
60-
6158
const visit = (ariaNode: AriaNode, node: Node) => {
6259
if (visited.has(node))
6360
return;
@@ -91,10 +88,12 @@ export function generateAriaTree(rootElement: Element, generation: number, optio
9188
}
9289
}
9390

94-
addElement(element);
9591
const childAriaNode = toAriaNode(element, options);
96-
if (childAriaNode)
92+
if (childAriaNode) {
93+
if (childAriaNode.ref)
94+
snapshot.elements.set(childAriaNode.ref, element);
9795
ariaNode.children.push(childAriaNode);
96+
}
9897
processElement(childAriaNode || ariaNode, element, ariaChildren);
9998
};
10099

@@ -150,9 +149,32 @@ export function generateAriaTree(rootElement: Element, generation: number, optio
150149
return snapshot;
151150
}
152151

152+
function ariaRef(element: Element, role: string, name: string, options?: { forAI?: boolean }): number | undefined {
153+
if (!options?.forAI)
154+
return undefined;
155+
156+
let ariaRef: AriaRef | undefined;
157+
ariaRef = (element as any)._ariaRef;
158+
if (!ariaRef || ariaRef.role !== role || ariaRef.name !== name) {
159+
ariaRef = { role, name, ref: ++lastRef };
160+
(element as any)._ariaRef = ariaRef;
161+
}
162+
return ariaRef.ref;
163+
}
164+
153165
function toAriaNode(element: Element, options?: { forAI?: boolean }): AriaNode | null {
154-
if (element.nodeName === 'IFRAME')
155-
return { role: 'iframe', name: '', children: [], props: {}, element, box: box(element), receivesPointerEvents: true };
166+
if (element.nodeName === 'IFRAME') {
167+
return {
168+
role: 'iframe',
169+
name: '',
170+
ref: ariaRef(element, 'iframe', '', options),
171+
children: [],
172+
props: {},
173+
element,
174+
box: box(element),
175+
receivesPointerEvents: true
176+
};
177+
}
156178

157179
const defaultRole = options?.forAI ? 'generic' : null;
158180
const role = roleUtils.getAriaRole(element) ?? defaultRole;
@@ -161,7 +183,17 @@ function toAriaNode(element: Element, options?: { forAI?: boolean }): AriaNode |
161183

162184
const name = normalizeWhiteSpace(roleUtils.getElementAccessibleName(element, false) || '');
163185
const receivesPointerEvents = roleUtils.receivesPointerEvents(element);
164-
const result: AriaNode = { role, name, children: [], props: {}, element, box: box(element), receivesPointerEvents };
186+
187+
const result: AriaNode = {
188+
role,
189+
name,
190+
ref: ariaRef(element, role, name, options),
191+
children: [],
192+
props: {},
193+
element,
194+
box: box(element),
195+
receivesPointerEvents
196+
};
165197

166198
if (roleUtils.kAriaCheckedRoles.includes(role))
167199
result.checked = roleUtils.getAriaChecked(element);
@@ -266,7 +298,7 @@ export type MatcherReceived = {
266298
};
267299

268300
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } {
269-
const snapshot = generateAriaTree(rootElement, 0);
301+
const snapshot = generateAriaTree(rootElement);
270302
const matches = matchesNodeDeep(snapshot.root, template, false, false);
271303
return {
272304
matches,
@@ -278,7 +310,7 @@ export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode
278310
}
279311

280312
export function getAllByAria(rootElement: Element, template: AriaTemplateNode): Element[] {
281-
const root = generateAriaTree(rootElement, 0).root;
313+
const root = generateAriaTree(rootElement).root;
282314
const matches = matchesNodeDeep(root, template, true, false);
283315
return matches.map(n => n.element);
284316
}
@@ -408,10 +440,10 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
408440
if (ariaNode.selected === true)
409441
key += ` [selected]`;
410442
if (options?.forAI && receivesPointerEvents(ariaNode)) {
411-
const id = ariaSnapshot.ids.get(ariaNode.element);
443+
const ref = ariaNode.ref;
412444
const cursor = hasPointerCursor(ariaNode) ? ' [cursor=pointer]' : '';
413-
if (id)
414-
key += ` [ref=s${ariaSnapshot.generation}e${id}]${cursor}`;
445+
if (ref)
446+
key += ` [ref=e${ref}]${cursor}`;
415447
}
416448

417449
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);

packages/injected/src/injectedScript.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ export class InjectedScript {
227227
this._engines.set('internal:attr', this._createNamedAttributeEngine());
228228
this._engines.set('internal:testid', this._createNamedAttributeEngine());
229229
this._engines.set('internal:role', createRoleEngine(true));
230-
this._engines.set('aria-ref', this._createAriaIdEngine());
230+
this._engines.set('aria-ref', this._createAriaRefEngine());
231231

232232
for (const { name, source } of options.customEngines)
233233
this._engines.set(name, this.eval(source));
@@ -300,15 +300,10 @@ export class InjectedScript {
300300
ariaSnapshot(node: Node, options?: { mode?: 'raw' | 'regex', forAI?: boolean }): string {
301301
if (node.nodeType !== Node.ELEMENT_NODE)
302302
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
303-
const generation = (this._lastAriaSnapshot?.generation || 0) + 1;
304-
this._lastAriaSnapshot = generateAriaTree(node as Element, generation, options);
303+
this._lastAriaSnapshot = generateAriaTree(node as Element, options);
305304
return renderAriaTree(this._lastAriaSnapshot, options);
306305
}
307306

308-
ariaSnapshotElement(snapshot: AriaSnapshot, elementId: number): Element | null {
309-
return snapshot.elements.get(elementId) || null;
310-
}
311-
312307
getAllByAria(document: Document, template: AriaTemplateNode): Element[] {
313308
return getAllByAria(document.documentElement, template);
314309
}
@@ -678,15 +673,12 @@ export class InjectedScript {
678673
return result;
679674
}
680675

681-
_createAriaIdEngine() {
676+
_createAriaRefEngine() {
682677
const queryAll = (root: SelectorRoot, selector: string): Element[] => {
683-
const match = selector.match(/^s(\d+)e(\d+)$/);
684-
if (!match)
685-
throw this.createStacklessError('Invalid aria-ref selector, should be of form s<number>e<number>');
686-
const [, generation, elementId] = match;
687-
if (this._lastAriaSnapshot?.generation !== +generation)
688-
throw this.createStacklessError(`Stale aria-ref, expected s${this._lastAriaSnapshot?.generation}e{number}, got ${selector}`);
689-
const result = this._lastAriaSnapshot?.elements?.get(+elementId);
678+
if (!selector.startsWith('e'))
679+
throw this.createStacklessError(`Invalid aria-ref selector "${selector}"`);
680+
const ref = +selector.substring(1);
681+
const result = this._lastAriaSnapshot?.elements?.get(ref);
690682
return result && result.isConnected ? [result] : [];
691683
};
692684
return { queryAll };

tests/page/page-aria-snapshot-ai.spec.ts

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,27 @@ it('should generate refs', async ({ page }) => {
2727
`);
2828

2929
const snapshot1 = await page.locator('body').ariaSnapshot(forAI);
30-
expect(snapshot1).toContain('- button "One" [ref=s1e3]');
31-
expect(snapshot1).toContain('- button "Two" [ref=s1e4]');
32-
expect(snapshot1).toContain('- button "Three" [ref=s1e5]');
30+
expect(snapshot1).toContainYaml(`
31+
- generic [ref=e1]:
32+
- button "One" [ref=e2]
33+
- button "Two" [ref=e3]
34+
- button "Three" [ref=e4]
35+
`);
36+
await expect(page.locator('aria-ref=e2')).toHaveText('One');
37+
await expect(page.locator('aria-ref=e3')).toHaveText('Two');
38+
await expect(page.locator('aria-ref=e4')).toHaveText('Three');
3339

34-
await expect(page.locator('aria-ref=s1e3')).toHaveText('One');
35-
await expect(page.locator('aria-ref=s1e4')).toHaveText('Two');
36-
await expect(page.locator('aria-ref=s1e5')).toHaveText('Three');
40+
await page.locator('aria-ref=e3').evaluate((e: HTMLElement) => {
41+
e.textContent = 'Not Two';
42+
});
3743

3844
const snapshot2 = await page.locator('body').ariaSnapshot(forAI);
39-
expect(snapshot2).toContain('- button "One" [ref=s2e3]');
40-
await expect(page.locator('aria-ref=s2e3')).toHaveText('One');
41-
42-
const e = await expect(page.locator('aria-ref=s1e3')).toHaveText('One').catch(e => e);
43-
expect(e.message).toContain('Error: Stale aria-ref, expected s2e{number}, got s1e3');
45+
expect(snapshot2).toContainYaml(`
46+
- generic [ref=e1]:
47+
- button "One" [ref=e2]
48+
- button "Not Two" [ref=e5]
49+
- button "Three" [ref=e4]
50+
`);
4451
});
4552

4653
it('should list iframes', async ({ page }) => {
@@ -80,15 +87,15 @@ it('ref mode can be used to stitch all frame snapshots', async ({ page, server }
8087
}
8188

8289
expect(await allFrameSnapshot(page)).toContainYaml(`
83-
- generic [ref=s1e2]:
84-
- iframe [ref=s1e3]:
85-
- generic [ref=s1e2]:
86-
- iframe [ref=s1e3]:
87-
- generic [ref=s1e3]: Hi, I'm frame
88-
- iframe [ref=s1e4]:
89-
- generic [ref=s1e3]: Hi, I'm frame
90-
- iframe [ref=s1e4]:
91-
- generic [ref=s1e3]: Hi, I'm frame
90+
- generic [ref=e1]:
91+
- iframe [ref=e2]:
92+
- generic [ref=e1]:
93+
- iframe [ref=e2]:
94+
- generic [ref=e2]: Hi, I'm frame
95+
- iframe [ref=e3]:
96+
- generic [ref=e2]: Hi, I'm frame
97+
- iframe [ref=e3]:
98+
- generic [ref=e2]: Hi, I'm frame
9299
`);
93100
});
94101

@@ -101,10 +108,10 @@ it('should not generate refs for hidden elements', async ({ page }) => {
101108

102109
const snapshot = await page.locator('body').ariaSnapshot(forAI);
103110
expect(snapshot).toContainYaml(`
104-
- generic [ref=s1e2]:
105-
- button "One" [ref=s1e3]
111+
- generic [ref=e1]:
112+
- button "One" [ref=e2]
106113
- button "Two"
107-
- button "Three" [ref=s1e5]
114+
- button "Three" [ref=e4]
108115
`);
109116
});
110117

@@ -133,12 +140,12 @@ it('should not generate refs for elements with pointer-events:none', async ({ pa
133140

134141
const snapshot = await page.locator('body').ariaSnapshot(forAI);
135142
expect(snapshot).toContainYaml(`
136-
- generic [ref=s1e2]:
143+
- generic [ref=e1]:
137144
- button "no-ref"
138-
- button "with-ref" [ref=s1e5]
139-
- button "with-ref" [ref=s1e8]
140-
- button "with-ref" [ref=s1e11]
141-
- generic [ref=s1e12]:
145+
- button "with-ref" [ref=e4]
146+
- button "with-ref" [ref=e7]
147+
- button "with-ref" [ref=e10]
148+
- generic [ref=e11]:
142149
- generic:
143150
- button "no-ref"
144151
`);
@@ -178,19 +185,19 @@ it('emit generic roles for nodes w/o roles', async ({ page }) => {
178185
const snapshot = await page.locator('body').ariaSnapshot(forAI);
179186

180187
expect(snapshot).toContainYaml(`
181-
- generic [ref=s1e3]:
182-
- generic [ref=s1e4]:
183-
- generic [ref=s1e5]:
188+
- generic [ref=e2]:
189+
- generic [ref=e3]:
190+
- generic [ref=e4]:
184191
- radio "Apple" [checked]
185-
- generic [ref=s1e7]: Apple
186-
- generic [ref=s1e8]:
187-
- generic [ref=s1e9]:
192+
- generic [ref=e6]: Apple
193+
- generic [ref=e7]:
194+
- generic [ref=e8]:
188195
- radio "Pear"
189-
- generic [ref=s1e11]: Pear
190-
- generic [ref=s1e12]:
191-
- generic [ref=s1e13]:
196+
- generic [ref=e10]: Pear
197+
- generic [ref=e11]:
198+
- generic [ref=e12]:
192199
- radio "Orange"
193-
- generic [ref=s1e15]: Orange
200+
- generic [ref=e14]: Orange
194201
`);
195202
});
196203

@@ -207,7 +214,7 @@ it('should collapse generic nodes', async ({ page }) => {
207214

208215
const snapshot = await page.locator('body').ariaSnapshot(forAI);
209216
expect(snapshot).toContainYaml(`
210-
- button \"Button\" [ref=s1e6]
217+
- button \"Button\" [ref=e5]
211218
`);
212219
});
213220

@@ -218,6 +225,6 @@ it('should include cursor pointer hint', async ({ page }) => {
218225

219226
const snapshot = await page.locator('body').ariaSnapshot(forAI);
220227
expect(snapshot).toContainYaml(`
221-
- button \"Button\" [ref=s1e3] [cursor=pointer]
228+
- button \"Button\" [ref=e2] [cursor=pointer]
222229
`);
223230
});

0 commit comments

Comments
 (0)