fix iframe snapshots and document Framer MCP flow
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:**
|
||||
|
||||
Reference in New Issue
Block a user