fix iframe snapshots and document Framer MCP flow

This commit is contained in:
Tommy D. Rossi
2026-02-05 14:49:32 +01:00
parent cd6d512d74
commit c4a413efba
7 changed files with 238 additions and 30 deletions
+60
View File
@@ -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 iframes 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
+1 -8
View File
@@ -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 }),
})
+139 -10
View File
@@ -206,6 +206,105 @@ function toAttributeMap(attributes?: string[]): Map<string, string> {
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<string, string>): { value: string; attr: string } | null {
const id = attributes.get('id')
if (id) {
@@ -746,8 +845,9 @@ function isSubstringOfAny(needle: string, haystack: Set<string>): 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<AriaSnapshotResult> {
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<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>()
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()
}
+8 -1
View File
@@ -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,
+10 -3
View File
@@ -12,7 +12,7 @@ import { getCdpUrl } from './utils.js'
* is assignable to this interface.
*/
export interface ICDPSession {
send(method: string, params?: object): Promise<unknown>
send(method: string, params?: object, sessionId?: string | null): Promise<unknown>
on(event: string, callback: (params: any) => void): unknown
off(event: string, callback: (params: any) => void): unknown
detach(): Promise<void>
@@ -76,6 +76,12 @@ export class CDPSession implements ICDPSession {
send<K extends keyof ProtocolMapping.Commands>(
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<ProtocolMapping.Commands[K]['returnType']> {
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) => {
+17 -8
View File
@@ -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,
+3
View File
@@ -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:**