10 KiB
title, description
| title | description |
|---|---|
| Playwright Accessibility Implementation Findings | Analysis of how Playwright implements accessibility snapshots and handles iframes |
Overview
This document contains findings from exploring the Playwright source code to understand:
- How Playwright implements accessibility snapshots (ariaSnapshot)
- Whether Playwright supports getting accessibility tree for iframes/child frames
- How Playwright handles frame locators for accessibility
- What CDP commands Playwright uses for accessibility
Key Findings
1. Playwright's Accessibility Implementation Strategy
Playwright does NOT use CDP Accessibility commands for ariaSnapshot. Instead, it uses:
- Browser-side JavaScript injection via
InjectedScriptclass - The injected script traverses the DOM directly using browser APIs
- No CDP
Accessibility.getFullAXTreeorAccessibility.getPartialAXTreecalls
Code Flow:
// Server-side (packages/playwright-core/src/server/frames.ts:1371)
async ariaSnapshot(progress: Progress, selector: string): Promise<string> {
return await this._retryWithProgressIfNotConnected(
progress,
selector,
true,
true,
handle => progress.race(handle.ariaSnapshot())
);
}
// Element handle (packages/playwright-core/src/server/dom.ts:757)
async ariaSnapshot(): Promise<string> {
return await this.evaluateInUtility(
([injected, element]) => injected.ariaSnapshot(element, { mode: 'expect' }),
{}
);
}
// Injected script (packages/injected/src/injectedScript.ts:305)
ariaSnapshot(node: Node, options: AriaTreeOptions): string {
return this.incrementalAriaSnapshot(node, options).full;
}
incrementalAriaSnapshot(node: Node, options: AriaTreeOptions & { track?: string }):
{ full: string, incremental?: string, iframeRefs: string[] } {
if (node.nodeType !== Node.ELEMENT_NODE)
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
const ariaSnapshot = generateAriaTree(node as Element, options);
const full = renderAriaTree(ariaSnapshot, options);
// ...
return { full, incremental, iframeRefs: ariaSnapshot.iframeRefs };
}
2. How Playwright Handles IFrames in Accessibility Snapshots
IFrames are recognized but their content is NOT included in the accessibility tree.
Evidence:
From packages/injected/src/ariaSnapshot.ts:217-232:
function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | null {
const active = element.ownerDocument.activeElement === element;
if (element.nodeName === 'IFRAME') {
const ariaNode: aria.AriaNode = {
role: 'iframe',
name: '',
children: [], // ⚠️ Empty children - no content from iframe
props: {},
box: computeBox(element),
receivesPointerEvents: true,
active
};
setAriaNodeElement(ariaNode, element);
computeAriaRef(ariaNode, options);
return ariaNode;
}
// ...
}
Key observations:
- IFrame elements are detected and added to the tree with
role: 'iframe' - The
childrenarray is ALWAYS empty for iframes - IFrame refs are tracked separately in
snapshot.iframeRefsarray - The content document of the iframe is NEVER traversed
- No attempt is made to access
iframe.contentDocumentoriframe.contentWindow
3. AriaSnapshot Return Value Structure
export type AriaSnapshot = {
root: aria.AriaNode;
elements: Map<string, Element>; // ref -> Element mapping
refs: Map<Element, string>; // Element -> ref mapping
iframeRefs: string[]; // List of iframe ref IDs
};
The iframeRefs array contains references to iframe elements but NOT their content.
4. CDP Accessibility Commands (Available but NOT Used)
The CDP protocol DOES support frame-specific accessibility queries:
// From protocol.d.ts
export type getFullAXTreeParameters = {
depth?: number;
frameId?: Page.FrameId; // ⚠️ Supports frame-specific queries!
}
export type getPartialAXTreeParameters = {
nodeId?: DOM.NodeId;
backendNodeId?: DOM.BackendNodeId;
objectId?: Runtime.RemoteObjectId;
fetchRelatives?: boolean;
}
However, Playwright does NOT use these CDP commands anywhere in the codebase.
Search results show:
- CDP types defined in
protocol.d.ts - No actual usage in Chromium implementation files
- Firefox uses a custom
Accessibility.getFullAXTreevia Juggler protocol
5. Why Playwright Uses DOM Traversal Instead of CDP
Advantages of DOM traversal approach:
- Cross-browser compatibility - Works in Firefox, WebKit, Chromium
- Full control - Can customize what gets included/excluded
- Performance - No serialization overhead for large trees
- Flexibility - Can implement custom filtering (visibility, aria roles, etc.)
Disadvantages:
- Cannot access iframe content - Browser security prevents cross-origin access
- Must execute in each frame separately - No single command for entire page tree
- Slower for deep trees - Must traverse DOM node-by-node
6. Current Iframe Handling Limitations
Based on code analysis:
Playwright CANNOT get accessibility tree for iframe content because:
- Security restrictions: JavaScript cannot access
iframe.contentDocumentfor cross-origin iframes - No CDP fallback: Playwright doesn't use CDP Accessibility commands that could bypass this
- By design: The
generateAriaTreefunction explicitly skips iframe children
To get iframe content accessibility tree, you would need to:
- Switch to the iframe's frame context
- Call
ariaSnapshot()again on that frame - Manually combine the results
Example:
// Get main frame snapshot
const mainSnapshot = await page.locator('body').ariaSnapshot();
// Get iframe content
const frameElement = await page.frameLocator('iframe');
const frame = await frameElement.owner();
const frameSnapshot = await frame.contentFrame().locator('body').ariaSnapshot();
// Results are separate - no automatic merging
7. Alternative: Using CDP Accessibility Commands
IF Playwriter wanted to support iframe content in accessibility snapshots, it could:
- Use
Accessibility.getFullAXTree({ frameId })for each frame - Recursively call it for all child frames
- Merge the results into a single tree
CDP Command:
await session.send('Accessibility.getFullAXTree', {
frameId: 'frame-id-here',
depth: -1 // unlimited depth
});
This would return the full accessibility tree including iframe content, but:
- Only works in Chromium (not Firefox/WebKit)
- Returns CDP's AXNode format, not Playwright's AriaNode format
- Would need conversion logic
Recommendations for Playwriter
Option 1: Multi-Frame Snapshot Approach (Current Playwright Pattern)
Get accessibility tree for each frame separately:
// Pseudo-code for MCP implementation
async function getAccessibilitySnapshot({ sessionId, includeFrames = false }) {
const page = getPage(sessionId);
// Get main frame snapshot
const mainSnapshot = await page.evaluate(() => {
return injected.ariaSnapshot(document.body, { mode: 'ai' });
});
if (!includeFrames) {
return mainSnapshot;
}
// Get all iframe snapshots
const frames = page.frames();
const frameSnapshots = await Promise.all(
frames.slice(1).map(async (frame) => {
return {
frameId: frame.name() || frame.url(),
snapshot: await frame.evaluate(() => {
return injected.ariaSnapshot(document.body, { mode: 'ai' });
})
};
})
);
return {
main: mainSnapshot,
frames: frameSnapshots
};
}
Option 2: CDP-Based Approach (Chromium Only)
Use CDP Accessibility.getFullAXTree for each frame:
async function getFullAccessibilityTree({ sessionId }) {
const page = getPage(sessionId);
const frames = page.frames();
const trees = await Promise.all(
frames.map(async (frame) => {
const session = await frame._client; // Get CDP session
const { nodes } = await session.send('Accessibility.getFullAXTree', {
frameId: frame._id
});
return {
frameId: frame.url(),
nodes
};
})
);
// Convert CDP AXNode[] to Playwright AriaNode format
return convertCDPtoAria(trees);
}
Note: This would only work for Chromium, not Firefox/WebKit.
Option 3: Hybrid Approach (Recommended)
- Use Playwright's existing
ariaSnapshot()for the current frame - Detect iframes via
snapshot.iframeRefs - Recursively get snapshots for each iframe's content frame
- Mark iframe boundaries in the output
async function getRecursiveSnapshot({ sessionId, maxDepth = 3 }) {
const page = getPage(sessionId);
async function getFrameSnapshot(frame, depth = 0) {
if (depth >= maxDepth) return null;
// Get snapshot for this frame
const result = await frame.evaluate(() => {
return injected.incrementalAriaSnapshot(document.body, {
mode: 'ai',
refPrefix: `f${depth}_`
});
});
// Find iframe elements
const iframeElements = await frame.$$('iframe');
// Get snapshots for child frames
const childSnapshots = await Promise.all(
iframeElements.map(async (iframeEl) => {
const childFrame = await iframeEl.contentFrame();
if (!childFrame) return null;
return {
iframeSrc: await iframeEl.getAttribute('src'),
content: await getFrameSnapshot(childFrame, depth + 1)
};
})
);
return {
snapshot: result.full,
iframes: childSnapshots.filter(Boolean)
};
}
return await getFrameSnapshot(page.mainFrame());
}
Summary
| Feature | Playwright Support | Notes |
|---|---|---|
| Accessibility snapshot for current frame | ✅ Yes | Via injected script DOM traversal |
| Accessibility snapshot for iframe content | ❌ No | Iframes detected but content not included |
| CDP Accessibility commands | ❌ Not used | Available but Playwright doesn't use them |
| Cross-browser support | ✅ Yes | Works in all browsers via DOM traversal |
| Frame-specific queries via CDP | ⚠️ Available | frameId parameter exists but unused |
| Multi-frame snapshot | ⚠️ Manual | Must query each frame separately |
Bottom line: Playwright's ariaSnapshot() works on a single frame at a time. To get iframe content, you must:
- Get the iframe element
- Access its
contentFrame() - Call
ariaSnapshot()on that frame - Manually combine results
There is no built-in way to get a recursive accessibility tree that includes iframe content in a single call.