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.locator('[data-testid="external-iframe"]').waitFor({ timeout: 5000 })
await page.frameLocator('[data-testid="external-iframe"]').locator('[data-testid="iframe-button"]').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({ const { snapshot } = await getAriaSnapshot({
page, page,
frame, iframe: page.locator('[data-testid="external-iframe"]'),
wsUrl: getCdpUrl({ port: TEST_PORT }), wsUrl: getCdpUrl({ port: TEST_PORT }),
}) })
+139 -10
View File
@@ -206,6 +206,105 @@ function toAttributeMap(attributes?: string[]): Map<string, string> {
return result 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 { function getStableRefFromAttributes(attributes: Map<string, string>): { value: string; attr: string } | null {
const id = attributes.get('id') const id = attributes.get('id')
if (id) { if (id) {
@@ -746,8 +845,9 @@ function isSubstringOfAny(needle: string, haystack: Set<string>): boolean {
* await page.locator(selector).click() * 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 page: Page
iframe?: Locator
locator?: Locator locator?: Locator
refFilter?: (info: { role: string; name: string }) => boolean refFilter?: (info: { role: string; name: string }) => boolean
wsUrl?: string wsUrl?: string
@@ -755,26 +855,47 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
cdp?: ICDPSession cdp?: ICDPSession
}): Promise<AriaSnapshotResult> { }): Promise<AriaSnapshotResult> {
const session = cdp || await getCDPSessionForPage({ page, wsUrl }) const session = cdp || await getCDPSessionForPage({ page, wsUrl })
await session.send('DOM.enable') let iframeSessionId: string | null = null
await session.send('Accessibility.enable') 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 scopeAttr = 'data-pw-scope'
const scopeValue = crypto.randomUUID() const scopeValue = crypto.randomUUID()
let scopeApplied = false let scopeApplied = false
const scopeLocator = locator
try { try {
if (locator) { if (scopeLocator) {
await locator.evaluate((element, data) => { await scopeLocator.evaluate((element, data) => {
element.setAttribute(data.attr, data.value) element.setAttribute(data.attr, data.value)
}, { attr: scopeAttr, value: scopeValue }) }, { attr: scopeAttr, value: scopeValue })
scopeApplied = true 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) const { domById, domByBackendId, childrenByParent } = buildDomIndex(domNodes)
let scopeRootNodeId: Protocol.DOM.NodeId | null = null let scopeRootNodeId: Protocol.DOM.NodeId | null = null
let scopeRootBackendId: Protocol.DOM.BackendNodeId | null = null let scopeRootBackendId: Protocol.DOM.BackendNodeId | null = null
if (locator) { if (scopeLocator) {
scopeRootNodeId = findScopeRootNodeId(domNodes, scopeAttr, scopeValue) scopeRootNodeId = findScopeRootNodeId(domNodes, scopeAttr, scopeValue)
if (scopeRootNodeId) { if (scopeRootNodeId) {
const scopeNode = domById.get(scopeRootNodeId) const scopeNode = domById.get(scopeRootNodeId)
@@ -788,7 +909,8 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById) ? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById)
: null : 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>() const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>()
for (const node of axNodes) { 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, getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null,
} }
} finally { } finally {
if (scopeApplied && locator) { if (scopeApplied && scopeLocator) {
await locator.evaluate((element, attr) => { await scopeLocator.evaluate((element, attr) => {
element.removeAttribute(attr) element.removeAttribute(attr)
}, scopeAttr) }, 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) { if (!cdp) {
await session.detach() await session.detach()
} }
+8 -1
View File
@@ -1153,6 +1153,7 @@ export async function startPlayWriterCDPRelayServer({
if (method === 'Target.attachedToTarget') { if (method === 'Target.attachedToTarget') {
const targetParams = params as Protocol.Target.AttachedToTargetEvent const targetParams = params as Protocol.Target.AttachedToTargetEvent
const incomingSessionId = sessionId
const iframeParentFrameId = targetParams.targetInfo.parentFrameId const iframeParentFrameId = targetParams.targetInfo.parentFrameId
const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId
? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId ? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId
@@ -1200,7 +1201,13 @@ export async function startPlayWriterCDPRelayServer({
if (!alreadyConnected) { if (!alreadyConnected) {
sendToPlaywright({ sendToPlaywright({
message: { 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', method: 'Target.attachedToTarget',
params: targetParams params: targetParams
} as CDPEventBase, } as CDPEventBase,
+10 -3
View File
@@ -12,7 +12,7 @@ import { getCdpUrl } from './utils.js'
* is assignable to this interface. * is assignable to this interface.
*/ */
export interface ICDPSession { 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 on(event: string, callback: (params: any) => void): unknown
off(event: string, callback: (params: any) => void): unknown off(event: string, callback: (params: any) => void): unknown
detach(): Promise<void> detach(): Promise<void>
@@ -76,6 +76,12 @@ export class CDPSession implements ICDPSession {
send<K extends keyof ProtocolMapping.Commands>( send<K extends keyof ProtocolMapping.Commands>(
method: K, method: K,
params?: ProtocolMapping.Commands[K]['paramsType'][0], 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']> { ): Promise<ProtocolMapping.Commands[K]['returnType']> {
const id = ++this.messageId const id = ++this.messageId
const message: { const message: {
@@ -85,8 +91,9 @@ export class CDPSession implements ICDPSession {
sessionId?: string sessionId?: string
source?: 'playwriter' source?: 'playwriter'
} = { id, method, params, source: 'playwriter' } } = { id, method, params, source: 'playwriter' }
if (this.sessionId) { const resolvedSessionId = sessionId ?? this.sessionId
message.sessionId = this.sessionId if (resolvedSessionId) {
message.sessionId = resolvedSessionId
} }
return new Promise((resolve, reject) => { 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. * 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 crypto from 'node:crypto'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
@@ -531,7 +531,8 @@ export class PlaywrightExecutor {
} }
const accessibilitySnapshot = async (options: { const accessibilitySnapshot = async (options: {
page: Page page?: Page
iframe?: Locator
/** Optional locator to scope the snapshot to a subtree */ /** Optional locator to scope the snapshot to a subtree */
locator?: Locator locator?: Locator
search?: string | RegExp search?: string | RegExp
@@ -541,11 +542,16 @@ export class PlaywrightExecutor {
/** Only include interactive elements (default: true) */ /** Only include interactive elements (default: true) */
interactiveOnly?: boolean 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 // Use new in-page implementation via getAriaSnapshot
const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({ const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({
page: targetPage, page: resolvedPage,
iframe,
locator, locator,
wsUrl: getCdpUrl(this.cdpConfig), wsUrl: getCdpUrl(this.cdpConfig),
interactiveOnly, interactiveOnly,
@@ -559,13 +565,16 @@ export class PlaywrightExecutor {
refToLocator.set(entry.shortRef, locatorStr) refToLocator.set(entry.shortRef, locatorStr)
} }
} }
this.lastRefToLocator.set(targetPage, refToLocator) this.lastRefToLocator.set(resolvedPage, refToLocator)
const previousSnapshot = this.lastSnapshots.get(targetPage) const shouldCacheSnapshot = !iframe
this.lastSnapshots.set(targetPage, snapshotStr) 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 // Return diff if we have a previous snapshot and diff mode is enabled
if (showDiffSinceLastCall && previousSnapshot) { if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) {
const diffResult = createSmartDiff({ const diffResult = createSmartDiff({
oldContent: previousSnapshot, oldContent: previousSnapshot,
newContent: snapshotStr, newContent: snapshotStr,
+3
View File
@@ -70,6 +70,9 @@ playwriter -s 1 -e "await page.screenshot({ path: 'screenshot.png', scale: 'css'
# Get accessibility snapshot # Get accessibility snapshot
playwriter -s 1 -e "await accessibilitySnapshot({ page })" 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:** **Multiline code:**