feat: reuse Playwright's internal CDP session instead of creating new WebSocket connections

Switch from getCDPSessionForPage (creates a new WS via Target.attachToTarget)
to getExistingCDPSessionForPage which reuses Playwright's internal CRSession.
This is critical for the relay server where Target.attachToTarget is intercepted
and cannot create real new sessions.

Also add FrameLocator support in accessibilitySnapshot — FrameLocator (from
locator.contentFrame()) is now auto-resolved to the real Frame object needed
for OOPIF CDP session attachment.

- New PlaywrightCDPSessionAdapter wrapping Playwright's CDPSession as ICDPSession
- New getExistingCDPSessionForPage() using context.getExistingCDPSession()
- resolveFrame() helper converts FrameLocator to Frame via elementHandle().contentFrame()
- getAriaSnapshot() frame param now accepts Frame | FrameLocator
- Executor CDP cache uses ICDPSession, borrowed sessions detach as no-op
- Test for getExistingCDPSession through the relay
- Updated framer iframe guide with alternative page.frames() example
- Bump playwright submodule to @xmorse/playwright-core@1.59.2
- Fix styles-api.md import path
This commit is contained in:
Tommy D. Rossi
2026-02-06 18:03:26 +01:00
parent 78734bf1e8
commit 87188423b1
9 changed files with 179 additions and 29 deletions
+6 -1
View File
@@ -45,11 +45,16 @@ playwriter -s 1 -e "const iframe = page.locator(\"iframe[src*='plugins.framercdn
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:
- Run the accessibility snapshot on that iframe using `contentFrame()` (FrameLocator is auto-resolved to Frame):
```bash
playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await accessibilitySnapshot({ page, frame }));"
```
- Alternative: use `page.frames()` to get the Frame directly:
```bash
playwriter -s 1 -e "const frame = page.frames().find(f => f.url().includes('plugins.framercdn.com')); console.log(await accessibilitySnapshot({ page, frame }));"
```
- Validate the snapshot contains MCP UI text (confirms the panel is actually loaded):
```bash
playwriter -s 1 -e "const frame = await page.locator(\"iframe[src*='plugins.framercdn.com']\").contentFrame(); console.log(await accessibilitySnapshot({ page, frame, search: /Control Framer with MCP|Login With Google/ }));"
+47 -4
View File
@@ -1,7 +1,7 @@
// Accessibility snapshot pipeline: build raw AX tree, filter to a
// tree (interactive-only, labels/contexts, wrapper hoisting, ignored
// indent preservation), then render lines and locators.
import type { Page, Locator, ElementHandle, Frame } from '@xmorse/playwright-core'
import type { Page, Locator, ElementHandle, Frame, FrameLocator } from '@xmorse/playwright-core'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
@@ -723,6 +723,44 @@ function isSubstringOfAny(needle: string, haystack: Set<string>): boolean {
return false
}
// ============================================================================
// Frame Resolution
// ============================================================================
/**
* Resolve a FrameLocator to an actual Frame object. FrameLocator (returned by
* locator.contentFrame()) is a scoping helper that doesn't have CDP methods like
* frameId(). We need the real Frame from page.frames() for OOPIF session attachment.
*
* If a Frame is passed directly, it's returned as-is.
* If a FrameLocator is passed, we find the matching Frame by locating the iframe
* element and matching its src URL against page.frames().
*/
async function resolveFrame({ frame, page }: { frame?: Frame | FrameLocator; page: Page }): Promise<Frame | undefined> {
if (!frame) {
return undefined
}
// Frame has frameId(), FrameLocator does not
if (typeof (frame as Frame).frameId === 'function') {
return frame as Frame
}
// It's a FrameLocator — resolve to a Frame via the owner iframe element.
// Use elementHandle().contentFrame() which returns the actual Frame object
// directly from the <iframe> element, avoiding ambiguity when multiple
// frames share the same origin (e.g. framer plugin iframes).
const frameLocator = frame as FrameLocator
const owner = frameLocator.owner()
const handle = await owner.elementHandle()
if (!handle) {
throw new Error('Could not resolve FrameLocator to a Frame: iframe element not found')
}
const resolved = await handle.contentFrame()
if (!resolved) {
throw new Error('Could not resolve FrameLocator to a Frame: contentFrame() returned null')
}
return resolved
}
// ============================================================================
// Main Functions
// ============================================================================
@@ -748,7 +786,7 @@ function isSubstringOfAny(needle: string, haystack: Set<string>): boolean {
*/
export async function getAriaSnapshot({ page, frame, locator, refFilter, wsUrl, interactiveOnly = false, cdp }: {
page: Page
frame?: Frame
frame?: Frame | FrameLocator
locator?: Locator
refFilter?: (info: { role: string; name: string }) => boolean
wsUrl?: string
@@ -756,15 +794,20 @@ export async function getAriaSnapshot({ page, frame, locator, refFilter, wsUrl,
cdp?: ICDPSession
}): Promise<AriaSnapshotResult> {
const session = cdp || await getCDPSessionForPage({ page, wsUrl })
// Resolve FrameLocator to an actual Frame. FrameLocator (from locator.contentFrame())
// is a scoping helper without CDP access. We need the real Frame from page.frames()
// which has frameId() for OOPIF session attachment.
const resolvedFrame = await resolveFrame({ frame, page })
// For cross-origin iframes (OOPIFs), we need to attach to the iframe's target
// to get a separate CDP session. Same-origin iframes can use frameId directly.
let oopifSessionId: string | null = null
const frameId = frame?.frameId() ?? null
const frameId = resolvedFrame?.frameId() ?? null
if (frameId) {
const { targetInfos } = await session.send('Target.getTargets') as Protocol.Target.GetTargetsResponse
const frameUrl = frame!.url()
const frameUrl = resolvedFrame!.url()
const iframeTarget = targetInfos.find((t) => {
return t.type === 'iframe' && t.url === frameUrl
})
+48 -1
View File
@@ -1,5 +1,5 @@
import WebSocket from 'ws'
import type { Page } from '@xmorse/playwright-core'
import type { Page, CDPSession as PlaywrightCDPSession } from '@xmorse/playwright-core'
import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'
import type { CDPResponseBase, CDPEventBase } from './cdp-types.js'
import { getCdpUrl } from './utils.js'
@@ -233,3 +233,50 @@ export async function getCDPSessionForPage({ page, wsUrl }: { page: Page; wsUrl?
return cdp
}
/**
* Wraps Playwright's CDPSession (from context.getExistingCDPSession) into an ICDPSession.
* This reuses Playwright's internal CDP WebSocket instead of creating a new one,
* which is important for the relay server where Target.attachToTarget is intercepted.
*
* The adapter maps between devtools-protocol's ProtocolMapping types (used by our CDPSession)
* and Playwright's Protocol types (used by their CDPSession). Both are compatible since
* ICDPSession uses loose string-based method names.
*/
export class PlaywrightCDPSessionAdapter implements ICDPSession {
private _playwrightSession: PlaywrightCDPSession
constructor(playwrightSession: PlaywrightCDPSession) {
this._playwrightSession = playwrightSession
}
async send(method: string, params?: object): Promise<unknown> {
return await this._playwrightSession.send(method as never, params as never)
}
on(event: string, callback: (params: unknown) => void): this {
this._playwrightSession.on(event as never, callback as never)
return this
}
off(event: string, callback: (params: unknown) => void): this {
this._playwrightSession.off(event as never, callback as never)
return this
}
async detach(): Promise<void> {
await this._playwrightSession.detach()
}
}
/**
* Gets a CDP session for a page by reusing Playwright's internal existing CDP session.
* Unlike getCDPSessionForPage which creates a new WebSocket, this uses the same WS
* Playwright already has. Works through the relay because it doesn't call
* Target.attachToTarget.
*/
export async function getExistingCDPSessionForPage({ page }: { page: Page }): Promise<PlaywrightCDPSessionAdapter> {
const context = page.context()
const playwrightSession = await context.getExistingCDPSession(page)
return new PlaywrightCDPSessionAdapter(playwrightSession)
}
+12 -9
View File
@@ -3,7 +3,7 @@
* Used by both MCP and CLI to execute Playwright code with persistent state.
*/
import { Page, Frame, Browser, BrowserContext, chromium, Locator } from '@xmorse/playwright-core'
import { Page, Frame, Browser, BrowserContext, chromium, Locator, FrameLocator } from '@xmorse/playwright-core'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
@@ -16,7 +16,7 @@ import * as acorn from 'acorn'
import { createSmartDiff } from './diff-utils.js'
import { getCdpUrl } from './utils.js'
import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js'
import { getCDPSessionForPage, CDPSession, ICDPSession } from './cdp-session.js'
import { getCDPSessionForPage, CDPSession, ICDPSession, getExistingCDPSessionForPage } from './cdp-session.js'
import { Debugger } from './debugger.js'
import { Editor } from './editor.js'
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
@@ -218,7 +218,7 @@ export class PlaywrightExecutor {
private browserLogs: Map<string, string[]> = new Map()
private lastSnapshots: WeakMap<Page, string> = new WeakMap()
private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap()
private cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap()
private cdpSessionCache: WeakMap<Page, ICDPSession> = new WeakMap()
private scopedFs: ScopedFS
private sandboxedRequire: NodeRequire
@@ -533,8 +533,8 @@ export class PlaywrightExecutor {
const accessibilitySnapshot = async (options: {
page?: Page
/** Optional frame to scope the snapshot (e.g. from iframe.contentFrame()) */
frame?: Frame
/** Optional frame to scope the snapshot (e.g. from iframe.contentFrame() or page.frames()) */
frame?: Frame | FrameLocator
/** Optional locator to scope the snapshot to a subtree */
locator?: Locator
search?: string | RegExp
@@ -723,9 +723,10 @@ export class PlaywrightExecutor {
return cached
}
// Generate a fresh unique URL for each CDP session to avoid client ID conflicts
const wsUrl = getCdpUrl(this.cdpConfig)
const session = await getCDPSessionForPage({ page: options.page, wsUrl })
// Reuse Playwright's internal CDP session over the same WebSocket instead of
// creating a new WS connection. This is critical for the relay where
// Target.attachToTarget is intercepted and can't create real new sessions.
const session = await getExistingCDPSessionForPage({ page: options.page })
this.cdpSessionCache.set(options.page, session)
options.page.on('close', () => {
@@ -734,7 +735,9 @@ export class PlaywrightExecutor {
return
}
this.cdpSessionCache.delete(options.page)
cachedSession.close()
// Borrowed sessions: detach is a no-op since Playwright owns the underlying session.
// We just clean up the cache reference.
cachedSession.detach().catch(() => {})
})
return session
+1 -1
View File
@@ -1,6 +1,6 @@
export * from './cdp-relay.js'
export * from './utils.js'
export { CDPSession, getCDPSessionForPage } from './cdp-session.js'
export { CDPSession, getCDPSessionForPage, getExistingCDPSessionForPage, PlaywrightCDPSessionAdapter } from './cdp-session.js'
export type { ICDPSession } from './cdp-session.js'
export { Editor } from './editor.js'
export type { ReadResult, SearchMatch, EditResult } from './editor.js'
+38 -1
View File
@@ -8,7 +8,7 @@ import fs from 'node:fs'
import os from 'node:os'
import { imageSize } from 'image-size'
import { getCdpUrl } from './utils.js'
import { getCDPSessionForPage } from './cdp-session.js'
import { getCDPSessionForPage, getExistingCDPSessionForPage } from './cdp-session.js'
import type { CDPCommand } from './cdp-types.js'
import { screenshotWithAccessibilityLabels } from './aria-snapshot.js'
import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, js } from './test-utils.js'
@@ -575,6 +575,43 @@ describe('Snapshot & Screenshot Tests', () => {
await page.close()
}, 60000)
it('should support getExistingCDPSession through the relay (reusing Playwright WS)', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
const page = await browserContext.newPage()
await page.goto('https://example.com/')
await page.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
// Use the new getExistingCDPSessionForPage which reuses Playwright's internal WS
const cdpClient = await getExistingCDPSessionForPage({ page: cdpPage! })
// Should be able to send CDP commands just like the regular getCDPSessionForPage
const layoutMetrics = await cdpClient.send('Page.getLayoutMetrics')
expect(layoutMetrics).toBeDefined()
const metrics = layoutMetrics as { cssVisualViewport?: { clientWidth?: number } }
expect(metrics.cssVisualViewport).toBeDefined()
expect(metrics.cssVisualViewport!.clientWidth).toBeGreaterThan(0)
// Test DOM access
const document = await cdpClient.send('DOM.getDocument')
expect(document).toBeDefined()
await cdpClient.detach()
await browser.close()
await page.close()
}, 60000)
it('should get aria ref for locator using getAriaSnapshot', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
+25 -10
View File
@@ -251,6 +251,9 @@ importers:
dotenv:
specifier: ^16.4.5
version: 16.6.1
get-stream:
specifier: ^9.0.1
version: 9.0.1
https-proxy-agent:
specifier: 7.0.6
version: 7.0.6
@@ -2449,6 +2452,9 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@sentry-internal/browser-utils@9.47.1':
resolution: {integrity: sha512-twv6YhrUlPkvKz4/iQDH4KHgcv9t4cMjmZPf4/dCSCXn4/GOjzjx2d74c1w+1KOdS7lcsQzI+MtbK6SeYLiGfQ==}
engines: {node: '>=18'}
@@ -4229,6 +4235,10 @@ packages:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
get-stream@9.0.1:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
get-symbol-description@1.1.0:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
@@ -4567,6 +4577,10 @@ packages:
resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==}
engines: {node: '>=0.10.0'}
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
@@ -8902,6 +8916,8 @@ snapshots:
'@rtsao/scc@1.1.0': {}
'@sec-ant/readable-stream@0.4.1': {}
'@sentry-internal/browser-utils@9.47.1':
dependencies:
'@sentry/core': 9.47.1
@@ -9514,14 +9530,6 @@ snapshots:
optionalDependencies:
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)
'@vitest/mocker@4.0.8(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))':
dependencies:
'@vitest/spy': 4.0.8
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)
'@vitest/mocker@4.0.8(vite@7.2.2(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))':
dependencies:
'@vitest/spy': 4.0.8
@@ -9573,7 +9581,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)
vitest: 4.0.8(@types/node@25.2.0)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)
'@vitest/utils@4.0.16':
dependencies:
@@ -11180,6 +11188,11 @@ snapshots:
dependencies:
pump: 3.0.3
get-stream@9.0.1:
dependencies:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
get-symbol-description@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -11569,6 +11582,8 @@ snapshots:
is-stream@1.1.0: {}
is-stream@4.0.1: {}
is-string@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -13655,7 +13670,7 @@ snapshots:
vitest@4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0):
dependencies:
'@vitest/expect': 4.0.8
'@vitest/mocker': 4.0.8(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))
'@vitest/mocker': 4.0.8(vite@7.2.2(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))
'@vitest/pretty-format': 4.0.8
'@vitest/runner': 4.0.8
'@vitest/snapshot': 4.0.8
+1 -1
View File
@@ -6,7 +6,7 @@ The getStylesForLocator function inspects CSS styles applied to an element, simi
```ts
import type { ICDPSession } from './cdp-session.js';
import type { Locator } from 'playwright-core';
import type { Locator } from '@xmorse/playwright-core';
export interface StyleSource {
url: string;
line: number;