From c4a413efba5b0d1b86fc8cdbb01aab750ebb1116 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Thu, 5 Feb 2026 14:49:32 +0100 Subject: [PATCH] fix iframe snapshots and document Framer MCP flow --- docs/framer-iframe-snapshot-guide.md | 60 +++++++++++ playwriter/src/aria-snapshot.test.ts | 9 +- playwriter/src/aria-snapshot.ts | 149 +++++++++++++++++++++++++-- playwriter/src/cdp-relay.ts | 9 +- playwriter/src/cdp-session.ts | 13 ++- playwriter/src/executor.ts | 25 +++-- playwriter/src/skill.md | 3 + 7 files changed, 238 insertions(+), 30 deletions(-) create mode 100644 docs/framer-iframe-snapshot-guide.md diff --git a/docs/framer-iframe-snapshot-guide.md b/docs/framer-iframe-snapshot-guide.md new file mode 100644 index 0000000..0b15cb2 --- /dev/null +++ b/docs/framer-iframe-snapshot-guide.md @@ -0,0 +1,60 @@ +--- +title: Framer Plugin Iframe Snapshot Guide +description: Step-by-step instructions to open the Framer MCP plugin iframe and run accessibilitySnapshot on it. +prompt: | + Create a concise step-by-step guide to open the Framer plugin iframe and + verify accessibilitySnapshot on that iframe using playwriter CLI. Include the + exact Framer project URL and the plugins.framercdn iframe URL. Also document + the Command+K workflow to open the MCP plugin: press Command+K, search for MCP + in the command palette, press Enter, then wait ~1 second for the iframe to + appear. Reference any files read for context. + + Sources: + - @/Users/morse/Documents/GitHub/playwriter/tmp/session-ses_437d9a10bffeFmy3k3hVcAFZnf.md + - @/Users/morse/Documents/GitHub/playwriter/tmp/session-ses_43807a563ffe1tCS0FWK79IpcV.md +--- + +## Step-by-step + +- Open the Framer project URL in Chrome: + https://framer.com/projects/unframer-source--XOxwdyyCrFEE9uKnKFPq-6gX7n?node=augiA20Il + +- Wait for the editor UI to finish loading (toolbar visible) before opening the command palette. + +- Press Command+K to open the command palette. + +- Verify the palette is open (search for the palette input in the snapshot output): +```bash +playwriter -s 1 -e "console.log(await accessibilitySnapshot({ page, search: /Command palette|Search commands|Type a command/ }));" +``` + +- Search for **MCP**, press Enter, then wait about 1 second for the plugin iframe to appear. + +- Verify the plugin iframe exists (should include `plugins.framercdn.com`): +```bash +playwriter -s 1 -e "const iframes = await page.locator('iframe').all(); for (const f of iframes) { console.log(await f.getAttribute('src')); }" +``` + +- Wait until the MCP iframe is present (verifies the action worked): +```bash +playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); await iframe.first().waitFor({ timeout: 10000 }); console.log('iframe ready');" +``` + +- Grab the iframe’s locator by URL: +```bash +playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); console.log(await iframe.count());" +``` + +- Run the accessibility snapshot on that iframe: +```bash +playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); console.log(await accessibilitySnapshot({ page, iframe }));" +``` + +- Validate the snapshot contains MCP UI text (confirms the panel is actually loaded): +```bash +playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn.com']\"); console.log(await accessibilitySnapshot({ page, iframe, search: /Control Framer with MCP|Login With Google/ }));" +``` + +## Expected iframe URL + +- https://nw12xtr7iedsczg1le9s9pqfl.plugins.framercdn.com/?mode=canvas diff --git a/playwriter/src/aria-snapshot.test.ts b/playwriter/src/aria-snapshot.test.ts index 150a876..c7fd931 100644 --- a/playwriter/src/aria-snapshot.test.ts +++ b/playwriter/src/aria-snapshot.test.ts @@ -178,16 +178,9 @@ describe('aria-snapshot', () => { await page.locator('[data-testid="external-iframe"]').waitFor({ timeout: 5000 }) await page.frameLocator('[data-testid="external-iframe"]').locator('[data-testid="iframe-button"]').waitFor({ timeout: 5000 }) - const frame = page.frames().find((candidate) => { - return candidate.url().includes('/iframe.html') - }) - if (!frame) { - throw new Error('Iframe frame not found') - } - const { snapshot } = await getAriaSnapshot({ page, - frame, + iframe: page.locator('[data-testid="external-iframe"]'), wsUrl: getCdpUrl({ port: TEST_PORT }), }) diff --git a/playwriter/src/aria-snapshot.ts b/playwriter/src/aria-snapshot.ts index 4711a07..2e80c13 100644 --- a/playwriter/src/aria-snapshot.ts +++ b/playwriter/src/aria-snapshot.ts @@ -206,6 +206,105 @@ function toAttributeMap(attributes?: string[]): Map { return result } +function getAttributeValue(attributes: string[] | undefined, name: string): string | null { + if (!attributes) { + return null + } + const index = attributes.indexOf(name) + if (index === -1) { + return null + } + return attributes[index + 1] ?? null +} + +function normalizeUrlForCompare(url: string): string { + try { + const parsed = new URL(url) + return `${parsed.origin}${parsed.pathname}` + } catch { + return url + } +} + +// Resolve a locator -> iframe target session. +// CDP flow: +// - DOM.getDocument + DOM.querySelector + DOM.describeNode: find the iframe element and read its src + frameId. +// - Target.getTargets: list all CDP targets (including OOPIF iframe targets). +// - Target.attachToTarget: attach to the matching iframe target to obtain the child sessionId. +// If no iframe target is found, we fall back to the iframe element's frameId for same-process iframes. +async function resolveIframeTarget({ + session, + iframe, +}: { + session: ICDPSession + iframe: Locator +}): Promise<{ sessionId: string | null; targetId: string | null; frameId: Protocol.Page.FrameId | null }> { + const attrName = 'data-pw-iframe-scope' + const attrValue = crypto.randomUUID() + + await iframe.evaluate((element, data) => { + element.setAttribute(data.name, data.value) + }, { name: attrName, value: attrValue }) + + try { + const { root } = await session.send('DOM.getDocument') as Protocol.DOM.GetDocumentResponse + const { nodeId } = await session.send('DOM.querySelector', { + nodeId: root.nodeId, + selector: `[${attrName}="${attrValue}"]`, + }) as Protocol.DOM.QuerySelectorResponse + + if (!nodeId) { + throw new Error('Iframe locator did not resolve to a DOM node') + } + + const { node } = await session.send('DOM.describeNode', { nodeId }) as Protocol.DOM.DescribeNodeResponse + const iframeSrc = getAttributeValue(node.attributes, 'src') + if (!iframeSrc) { + throw new Error('Iframe element has no src attribute') + } + const frameId = node.frameId ?? null + + const { targetInfos } = await session.send('Target.getTargets') as Protocol.Target.GetTargetsResponse + const iframeTargets = targetInfos.filter((target) => target.type === 'iframe') + const normalizedSrc = normalizeUrlForCompare(iframeSrc) + + const exactMatch = iframeTargets.find((target) => target.url === iframeSrc) + const normalizedMatch = iframeTargets.find((target) => normalizeUrlForCompare(target.url) === normalizedSrc) + const originMatch = (() => { + try { + const { origin } = new URL(iframeSrc) + return iframeTargets.find((target) => target.url.startsWith(origin)) + } catch { + return undefined + } + })() + + const target = exactMatch || normalizedMatch || originMatch + if (!target) { + if (!frameId) { + throw new Error(`No iframe target found for src: ${iframeSrc}`) + } + return { sessionId: null, targetId: null, frameId } + } + + const { sessionId } = await session.send('Target.attachToTarget', { + targetId: target.targetId, + flatten: true, + }) as Protocol.Target.AttachToTargetResponse + + return { sessionId, targetId: target.targetId, frameId } + } finally { + try { + await iframe.evaluate((element, data) => { + element.removeAttribute(data.name) + }, { name: attrName }) + } catch (error) { + throw new Error('Failed to cleanup iframe scope marker', { cause: error }) + } + } +} + + function getStableRefFromAttributes(attributes: Map): { value: string; attr: string } | null { const id = attributes.get('id') if (id) { @@ -746,8 +845,9 @@ function isSubstringOfAny(needle: string, haystack: Set): boolean { * await page.locator(selector).click() * ``` */ -export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interactiveOnly = false, cdp }: { +export async function getAriaSnapshot({ page, iframe, locator, refFilter, wsUrl, interactiveOnly = false, cdp }: { page: Page + iframe?: Locator locator?: Locator refFilter?: (info: { role: string; name: string }) => boolean wsUrl?: string @@ -755,26 +855,47 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac cdp?: ICDPSession }): Promise { const session = cdp || await getCDPSessionForPage({ page, wsUrl }) - await session.send('DOM.enable') - await session.send('Accessibility.enable') + let iframeSessionId: string | null = null + let iframeTargetId: string | null = null + let iframeFrameId: Protocol.Page.FrameId | null = null + if (iframe) { + const resolved = await resolveIframeTarget({ session, iframe }) + iframeSessionId = resolved.sessionId + iframeTargetId = resolved.targetId + iframeFrameId = resolved.frameId + if (!iframeSessionId && !iframeFrameId) { + throw new Error('Failed to resolve iframe target or frameId') + } + } + + if (iframeSessionId) { + try { + await session.send('Runtime.runIfWaitingForDebugger', undefined, iframeSessionId) + } catch (error) { + throw new Error('Failed to resume iframe target session', { cause: error }) + } + } + await session.send('DOM.enable', undefined, iframeSessionId) + await session.send('Accessibility.enable', undefined, iframeSessionId) const scopeAttr = 'data-pw-scope' const scopeValue = crypto.randomUUID() let scopeApplied = false + const scopeLocator = locator try { - if (locator) { - await locator.evaluate((element, data) => { + if (scopeLocator) { + await scopeLocator.evaluate((element, data) => { element.setAttribute(data.attr, data.value) }, { attr: scopeAttr, value: scopeValue }) scopeApplied = true } - const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', { depth: -1, pierce: true }) as Protocol.DOM.GetFlattenedDocumentResponse + const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', { depth: -1, pierce: true }, iframeSessionId) as Protocol.DOM.GetFlattenedDocumentResponse const { domById, domByBackendId, childrenByParent } = buildDomIndex(domNodes) let scopeRootNodeId: Protocol.DOM.NodeId | null = null let scopeRootBackendId: Protocol.DOM.BackendNodeId | null = null - if (locator) { + if (scopeLocator) { scopeRootNodeId = findScopeRootNodeId(domNodes, scopeAttr, scopeValue) if (scopeRootNodeId) { const scopeNode = domById.get(scopeRootNodeId) @@ -788,7 +909,8 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac ? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById) : null - const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree') as Protocol.Accessibility.GetFullAXTreeResponse + const axParams = !iframeSessionId && iframeFrameId ? { frameId: iframeFrameId } : undefined + const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree', axParams, iframeSessionId) as Protocol.Accessibility.GetFullAXTreeResponse const axById = new Map() for (const node of axNodes) { @@ -1021,11 +1143,18 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null, } } finally { - if (scopeApplied && locator) { - await locator.evaluate((element, attr) => { + if (scopeApplied && scopeLocator) { + await scopeLocator.evaluate((element, attr) => { element.removeAttribute(attr) }, scopeAttr) } + if (iframeSessionId) { + try { + await session.send('Target.detachFromTarget', { sessionId: iframeSessionId }) + } catch (error) { + throw new Error(`Failed to detach iframe target ${iframeTargetId ?? 'unknown'}`, { cause: error }) + } + } if (!cdp) { await session.detach() } diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index 5217e30..0ba7b77 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -1153,6 +1153,7 @@ export async function startPlayWriterCDPRelayServer({ if (method === 'Target.attachedToTarget') { const targetParams = params as Protocol.Target.AttachedToTargetEvent + const incomingSessionId = sessionId const iframeParentFrameId = targetParams.targetInfo.parentFrameId const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId ? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId @@ -1200,7 +1201,13 @@ export async function startPlayWriterCDPRelayServer({ if (!alreadyConnected) { sendToPlaywright({ message: { - sessionId: iframeOwnerSessionId, + // Iframe targets must be routed to the parent page sessionId so Playwright attaches them under the right page. + // - iframeOwnerSessionId: derived parent session via parentFrameId -> page sessionId (frameId tracking). + // - incomingSessionId: extension event sessionId for the parent tab. + // The frameId mapping is racy: Target.attachedToTarget can arrive before Page.frameAttached/Page.frameNavigated populate frameIds. + // When iframeOwnerSessionId is missing we must fall back to incomingSessionId, otherwise Playwright receives the attach on the root + // session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations. + sessionId: iframeOwnerSessionId ?? incomingSessionId, method: 'Target.attachedToTarget', params: targetParams } as CDPEventBase, diff --git a/playwriter/src/cdp-session.ts b/playwriter/src/cdp-session.ts index 4ee390e..361cd78 100644 --- a/playwriter/src/cdp-session.ts +++ b/playwriter/src/cdp-session.ts @@ -12,7 +12,7 @@ import { getCdpUrl } from './utils.js' * is assignable to this interface. */ export interface ICDPSession { - send(method: string, params?: object): Promise + send(method: string, params?: object, sessionId?: string | null): Promise on(event: string, callback: (params: any) => void): unknown off(event: string, callback: (params: any) => void): unknown detach(): Promise @@ -76,6 +76,12 @@ export class CDPSession implements ICDPSession { send( method: K, params?: ProtocolMapping.Commands[K]['paramsType'][0], + // Some iframes are OOPIF targets with their own CDP session. Their frameId + // only exists inside that target session, so AX/DOM commands must be sent + // with the iframe's Target.attachToTarget sessionId instead of the page + // sessionId. This override lets us reuse the same websocket while routing + // a single command to the correct target session. + sessionId?: string | null, ): Promise { const id = ++this.messageId const message: { @@ -85,8 +91,9 @@ export class CDPSession implements ICDPSession { sessionId?: string source?: 'playwriter' } = { id, method, params, source: 'playwriter' } - if (this.sessionId) { - message.sessionId = this.sessionId + const resolvedSessionId = sessionId ?? this.sessionId + if (resolvedSessionId) { + message.sessionId = resolvedSessionId } return new Promise((resolve, reject) => { diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index 9eceb12..df316c1 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -3,7 +3,7 @@ * Used by both MCP and CLI to execute Playwright code with persistent state. */ -import { Page, Browser, BrowserContext, chromium, Locator } from 'playwright-core' +import { Page, Frame, Browser, BrowserContext, chromium, Locator } from 'playwright-core' import crypto from 'node:crypto' import fs from 'node:fs' import path from 'node:path' @@ -531,7 +531,8 @@ export class PlaywrightExecutor { } const accessibilitySnapshot = async (options: { - page: Page + page?: Page + iframe?: Locator /** Optional locator to scope the snapshot to a subtree */ locator?: Locator search?: string | RegExp @@ -541,11 +542,16 @@ export class PlaywrightExecutor { /** Only include interactive elements (default: true) */ interactiveOnly?: boolean }) => { - const { page: targetPage, locator, search, showDiffSinceLastCall = true, interactiveOnly = false } = options + const { page: targetPage, iframe, locator, search, showDiffSinceLastCall = true, interactiveOnly = false } = options + const resolvedPage = targetPage || page + if (!resolvedPage) { + throw new Error('accessibilitySnapshot requires a page') + } // Use new in-page implementation via getAriaSnapshot const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({ - page: targetPage, + page: resolvedPage, + iframe, locator, wsUrl: getCdpUrl(this.cdpConfig), interactiveOnly, @@ -559,13 +565,16 @@ export class PlaywrightExecutor { refToLocator.set(entry.shortRef, locatorStr) } } - this.lastRefToLocator.set(targetPage, refToLocator) + this.lastRefToLocator.set(resolvedPage, refToLocator) - const previousSnapshot = this.lastSnapshots.get(targetPage) - this.lastSnapshots.set(targetPage, snapshotStr) + const shouldCacheSnapshot = !iframe + const previousSnapshot = shouldCacheSnapshot ? this.lastSnapshots.get(resolvedPage) : undefined + if (shouldCacheSnapshot) { + this.lastSnapshots.set(resolvedPage, snapshotStr) + } // Return diff if we have a previous snapshot and diff mode is enabled - if (showDiffSinceLastCall && previousSnapshot) { + if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) { const diffResult = createSmartDiff({ oldContent: previousSnapshot, newContent: snapshotStr, diff --git a/playwriter/src/skill.md b/playwriter/src/skill.md index 16bcc06..935044b 100644 --- a/playwriter/src/skill.md +++ b/playwriter/src/skill.md @@ -70,6 +70,9 @@ playwriter -s 1 -e "await page.screenshot({ path: 'screenshot.png', scale: 'css' # Get accessibility snapshot playwriter -s 1 -e "await accessibilitySnapshot({ page })" + +# Get accessibility snapshot for a specific iframe +await accessibilitySnapshot({ iframe: page.locator('iframe[src*="iframe.html"]') }) ``` **Multiline code:**