--- title: Playwright Accessibility Implementation Findings description: Analysis of how Playwright implements accessibility snapshots and handles iframes --- ## Overview This document contains findings from exploring the Playwright source code to understand: 1. How Playwright implements accessibility snapshots (ariaSnapshot) 2. Whether Playwright supports getting accessibility tree for iframes/child frames 3. How Playwright handles frame locators for accessibility 4. 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 `InjectedScript` class - The injected script traverses the DOM directly using browser APIs - No CDP `Accessibility.getFullAXTree` or `Accessibility.getPartialAXTree` calls #### Code Flow: ```typescript // Server-side (packages/playwright-core/src/server/frames.ts:1371) async ariaSnapshot(progress: Progress, selector: string): Promise { 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 { 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`: ```typescript 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 `children` array is ALWAYS empty for iframes - IFrame refs are tracked separately in `snapshot.iframeRefs` array - The content document of the iframe is NEVER traversed - No attempt is made to access `iframe.contentDocument` or `iframe.contentWindow` ### 3. AriaSnapshot Return Value Structure ```typescript export type AriaSnapshot = { root: aria.AriaNode elements: Map // ref -> Element mapping refs: Map // 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: ```typescript // 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.getFullAXTree` via Juggler protocol ### 5. Why Playwright Uses DOM Traversal Instead of CDP **Advantages of DOM traversal approach:** 1. **Cross-browser compatibility** - Works in Firefox, WebKit, Chromium 2. **Full control** - Can customize what gets included/excluded 3. **Performance** - No serialization overhead for large trees 4. **Flexibility** - Can implement custom filtering (visibility, aria roles, etc.) **Disadvantages:** 1. **Cannot access iframe content** - Browser security prevents cross-origin access 2. **Must execute in each frame separately** - No single command for entire page tree 3. **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:** 1. **Security restrictions**: JavaScript cannot access `iframe.contentDocument` for cross-origin iframes 2. **No CDP fallback**: Playwright doesn't use CDP Accessibility commands that could bypass this 3. **By design**: The `generateAriaTree` function explicitly skips iframe children **To get iframe content accessibility tree, you would need to:** 1. Switch to the iframe's frame context 2. Call `ariaSnapshot()` again on that frame 3. Manually combine the results Example: ```typescript // 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: 1. Use `Accessibility.getFullAXTree({ frameId })` for each frame 2. Recursively call it for all child frames 3. Merge the results into a single tree **CDP Command:** ```typescript 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: ```typescript // 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: ```typescript 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) 1. Use Playwright's existing `ariaSnapshot()` for the current frame 2. Detect iframes via `snapshot.iframeRefs` 3. Recursively get snapshots for each iframe's content frame 4. Mark iframe boundaries in the output ```typescript 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: 1. Get the iframe element 2. Access its `contentFrame()` 3. Call `ariaSnapshot()` on that frame 4. Manually combine results There is no built-in way to get a recursive accessibility tree that includes iframe content in a single call.