Skip to content

Commit a8152d4

Browse files
authored
fix: include iframes into the a11y snapshot (#12579)
1 parent 18e3e6a commit a8152d4

5 files changed

Lines changed: 248 additions & 5 deletions

File tree

docs/api/puppeteer.snapshotoptions.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@ Default
3535
</th></tr></thead>
3636
<tbody><tr><td>
3737

38+
<span id="includeiframes">includeIframes</span>
39+
40+
</td><td>
41+
42+
`optional`
43+
44+
</td><td>
45+
46+
boolean
47+
48+
</td><td>
49+
50+
If true, gets accessibility trees for each of the iframes in the frame subtree.
51+
52+
</td><td>
53+
54+
`false`
55+
56+
</td></tr>
57+
<tr><td>
58+
3859
<span id="interestingonly">interestingOnly</span>
3960

4061
</td><td>

packages/puppeteer-core/src/bidi/Frame.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export class BidiFrame extends Frame {
115115
this,
116116
),
117117
};
118-
this.accessibility = new Accessibility(this.realms.default);
118+
this.accessibility = new Accessibility(this.realms.default, this._id);
119119
}
120120

121121
#initialize(): void {

packages/puppeteer-core/src/cdp/Accessibility.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ export interface SnapshotOptions {
9999
* @defaultValue `true`
100100
*/
101101
interestingOnly?: boolean;
102+
/**
103+
* If true, gets accessibility trees for each of the iframes in the frame
104+
* subtree.
105+
*
106+
* @defaultValue `false`
107+
*/
108+
includeIframes?: boolean;
102109
/**
103110
* Root node to get the accessibility tree for
104111
* @defaultValue The root node of the entire page.
@@ -130,12 +137,14 @@ export interface SnapshotOptions {
130137
*/
131138
export class Accessibility {
132139
#realm: Realm;
140+
#frameId: string;
133141

134142
/**
135143
* @internal
136144
*/
137-
constructor(realm: Realm) {
145+
constructor(realm: Realm, frameId = '') {
138146
this.#realm = realm;
147+
this.#frameId = frameId;
139148
}
140149

141150
/**
@@ -180,9 +189,16 @@ export class Accessibility {
180189
public async snapshot(
181190
options: SnapshotOptions = {},
182191
): Promise<SerializedAXNode | null> {
183-
const {interestingOnly = true, root = null} = options;
192+
const {
193+
interestingOnly = true,
194+
root = null,
195+
includeIframes = false,
196+
} = options;
184197
const {nodes} = await this.#realm.environment.client.send(
185198
'Accessibility.getFullAXTree',
199+
{
200+
frameId: this.#frameId,
201+
},
186202
);
187203
let backendNodeId: number | undefined;
188204
if (root) {
@@ -195,15 +211,44 @@ export class Accessibility {
195211
backendNodeId = node.backendNodeId;
196212
}
197213
const defaultRoot = AXNode.createTree(this.#realm, nodes);
214+
const populateIframes = async (root: AXNode): Promise<void> => {
215+
if (root.payload.role?.value === 'Iframe') {
216+
if (!root.payload.backendDOMNodeId) {
217+
return;
218+
}
219+
using handle = (await this.#realm.adoptBackendNode(
220+
root.payload.backendDOMNodeId,
221+
)) as ElementHandle<Element>;
222+
if (!handle || !('contentFrame' in handle)) {
223+
return;
224+
}
225+
const frame = await handle.contentFrame();
226+
if (!frame) {
227+
return;
228+
}
229+
const iframeSnapshot = await frame.accessibility.snapshot(options);
230+
root.iframeSnapshot = iframeSnapshot ?? undefined;
231+
}
232+
for (const child of root.children) {
233+
await populateIframes(child);
234+
}
235+
};
236+
198237
let needle: AXNode | null = defaultRoot;
199238
if (!defaultRoot) {
200239
return null;
201240
}
241+
242+
if (includeIframes) {
243+
await populateIframes(defaultRoot);
244+
}
245+
202246
if (backendNodeId) {
203247
needle = defaultRoot.find(node => {
204248
return node.payload.backendDOMNodeId === backendNodeId;
205249
});
206250
}
251+
207252
if (!needle) {
208253
return null;
209254
}
@@ -237,6 +282,12 @@ export class Accessibility {
237282
if (children.length) {
238283
serializedNode.children = children;
239284
}
285+
if (node.iframeSnapshot) {
286+
if (!serializedNode.children) {
287+
serializedNode.children = [];
288+
}
289+
serializedNode.children.push(node.iframeSnapshot);
290+
}
240291
return [serializedNode];
241292
}
242293

@@ -245,7 +296,7 @@ export class Accessibility {
245296
node: AXNode,
246297
insideControl: boolean,
247298
): void {
248-
if (node.isInteresting(insideControl)) {
299+
if (node.isInteresting(insideControl) || node.iframeSnapshot) {
249300
collection.add(node);
250301
}
251302
if (node.isLeafNode()) {
@@ -261,6 +312,7 @@ export class Accessibility {
261312
class AXNode {
262313
public payload: Protocol.Accessibility.AXNode;
263314
public children: AXNode[] = [];
315+
public iframeSnapshot?: SerializedAXNode;
264316

265317
#richlyEditable = false;
266318
#editable = false;

packages/puppeteer-core/src/cdp/Frame.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export class CdpFrame extends Frame {
7979
),
8080
};
8181

82-
this.accessibility = new Accessibility(this.worlds[MAIN_WORLD]);
82+
this.accessibility = new Accessibility(this.worlds[MAIN_WORLD], frameId);
8383

8484
this.on(FrameEvent.FrameSwappedByActivation, () => {
8585
// Emulate loading process for swapped frames.

test/src/accessibility.spec.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import expect from 'expect';
1010
import type {SerializedAXNode} from 'puppeteer-core/internal/cdp/Accessibility.js';
1111

1212
import {getTestState, setupTestBrowserHooks} from './mocha-utils.js';
13+
import {attachFrame} from './utils.js';
1314

1415
describe('Accessibility', function () {
1516
setupTestBrowserHooks();
@@ -242,6 +243,175 @@ describe('Accessibility', function () {
242243
assert(snapshot.children[0]);
243244
expect(snapshot.children[0]!.multiselectable).toEqual(true);
244245
});
246+
247+
describe('iframes', () => {
248+
it('should not include iframe data if not requested', async () => {
249+
const {page, server} = await getTestState();
250+
await attachFrame(page, 'frame1', server.EMPTY_PAGE);
251+
const frame1 = page.frames()[1];
252+
await frame1!.evaluate(() => {
253+
const button = document.createElement('button');
254+
button.innerText = 'value1';
255+
document.body.appendChild(button);
256+
});
257+
const snapshot = await page.accessibility.snapshot({
258+
interestingOnly: true,
259+
});
260+
expect(snapshot).toMatchObject({
261+
role: 'RootWebArea',
262+
name: '',
263+
});
264+
});
265+
266+
it('same-origin iframe (interesting only)', async () => {
267+
const {page, server} = await getTestState();
268+
await attachFrame(page, 'frame1', server.EMPTY_PAGE);
269+
const frame1 = page.frames()[1];
270+
await frame1!.evaluate(() => {
271+
const button = document.createElement('button');
272+
button.innerText = 'value1';
273+
document.body.appendChild(button);
274+
});
275+
const snapshot = await page.accessibility.snapshot({
276+
interestingOnly: true,
277+
includeIframes: true,
278+
});
279+
expect(snapshot).toMatchObject({
280+
role: 'RootWebArea',
281+
name: '',
282+
children: [
283+
{
284+
role: 'Iframe',
285+
name: '',
286+
children: [
287+
{
288+
role: 'RootWebArea',
289+
name: '',
290+
children: [
291+
{
292+
role: 'button',
293+
name: 'value1',
294+
},
295+
],
296+
},
297+
],
298+
},
299+
],
300+
});
301+
});
302+
303+
it('cross-origin iframe (interesting only)', async () => {
304+
const {page, server} = await getTestState();
305+
await attachFrame(
306+
page,
307+
'frame1',
308+
server.CROSS_PROCESS_PREFIX + '/empty.html',
309+
);
310+
const frame1 = page.frames()[1];
311+
await frame1!.evaluate(() => {
312+
const button = document.createElement('button');
313+
button.innerText = 'value1';
314+
document.body.appendChild(button);
315+
});
316+
const snapshot = await page.accessibility.snapshot({
317+
interestingOnly: true,
318+
includeIframes: true,
319+
});
320+
expect(snapshot).toMatchObject({
321+
role: 'RootWebArea',
322+
name: '',
323+
children: [
324+
{
325+
role: 'Iframe',
326+
name: '',
327+
children: [
328+
{
329+
role: 'RootWebArea',
330+
name: '',
331+
children: [
332+
{
333+
role: 'button',
334+
name: 'value1',
335+
},
336+
],
337+
},
338+
],
339+
},
340+
],
341+
});
342+
});
343+
344+
it('same-origin iframe (all nodes)', async () => {
345+
const {page, server} = await getTestState();
346+
await attachFrame(page, 'frame1', server.EMPTY_PAGE);
347+
const frame1 = page.frames()[1];
348+
await frame1!.evaluate(() => {
349+
const button = document.createElement('button');
350+
button.innerText = 'value1';
351+
document.body.appendChild(button);
352+
});
353+
const snapshot = await page.accessibility.snapshot({
354+
interestingOnly: false,
355+
includeIframes: true,
356+
});
357+
expect(snapshot).toMatchObject({
358+
role: 'RootWebArea',
359+
name: '',
360+
children: [
361+
{
362+
role: 'none',
363+
children: [
364+
{
365+
role: 'generic',
366+
name: '',
367+
children: [
368+
{
369+
role: 'Iframe',
370+
name: '',
371+
children: [
372+
{
373+
role: 'RootWebArea',
374+
name: '',
375+
children: [
376+
{
377+
role: 'none',
378+
children: [
379+
{
380+
role: 'generic',
381+
name: '',
382+
children: [
383+
{
384+
role: 'button',
385+
name: 'value1',
386+
children: [
387+
{
388+
role: 'StaticText',
389+
name: 'value1',
390+
children: [
391+
{
392+
role: 'InlineTextBox',
393+
},
394+
],
395+
},
396+
],
397+
},
398+
],
399+
},
400+
],
401+
},
402+
],
403+
},
404+
],
405+
},
406+
],
407+
},
408+
],
409+
},
410+
],
411+
});
412+
});
413+
});
414+
245415
it('keyshortcuts', async () => {
246416
const {page} = await getTestState();
247417

0 commit comments

Comments
 (0)