From d51d8b8e729472e3a0aa47004228e5a6661391ca Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 26 Jan 2026 14:56:17 +0100 Subject: [PATCH] feat(cdp): allow iframe targets and serialize extension builds --- extension/src/background.ts | 39 +++++++++++++++++++++++++++--------- playwriter/src/cdp-relay.ts | 18 ++--------------- playwriter/src/mcp.test.ts | 5 +++-- playwriter/src/test-utils.ts | 11 +++++++++- 4 files changed, 45 insertions(+), 28 deletions(-) diff --git a/extension/src/background.ts b/extension/src/background.ts index f9b2a1d..a713497 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -12,7 +12,7 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } -let childSessions: Map = new Map() +let childSessions: Map = new Map() let nextSessionId = 1 let tabGroupQueue: Promise = Promise.resolve() @@ -581,11 +581,11 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise { } if (!targetTab && msg.params.sessionId) { - const parentTabId = childSessions.get(msg.params.sessionId) - if (parentTabId) { - targetTabId = parentTabId - targetTab = store.getState().tabs.get(parentTabId) - logger.debug('Found parent tab for child session:', msg.params.sessionId, 'tabId:', parentTabId) + const childSession = childSessions.get(msg.params.sessionId) + if (childSession) { + targetTabId = childSession.tabId + targetTab = store.getState().tabs.get(childSession.tabId) + logger.debug('Found parent tab for child session:', msg.params.sessionId, 'tabId:', childSession.tabId) } } @@ -672,7 +672,8 @@ function onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string if (method === 'Target.attachedToTarget' && params?.sessionId) { logger.debug('Child target attached:', params.sessionId, 'for tab:', source.tabId) - childSessions.set(params.sessionId, source.tabId!) + const targetId = params.targetInfo?.targetId as string | undefined + childSessions.set(params.sessionId, { tabId: source.tabId!, targetId }) } if (method === 'Target.detachedFromTarget' && params?.sessionId) { @@ -726,7 +727,17 @@ function onDebuggerDetach(source: chrome.debugger.Debuggee, reason: `${chrome.de } for (const [childSessionId, parentTabId] of childSessions.entries()) { - if (parentTabId === tabId) { + if (parentTabId.tabId === tabId) { + const childDetachParams: Protocol.Target.DetachedFromTargetEvent = parentTabId.targetId + ? { sessionId: childSessionId, targetId: parentTabId.targetId } + : { sessionId: childSessionId } + sendMessage({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: childDetachParams, + }, + }) logger.debug('Cleaning up child session:', childSessionId, 'for tab:', tabId) childSessions.delete(childSessionId) } @@ -849,7 +860,17 @@ function detachTab(tabId: number, shouldDetachDebugger: boolean): void { }) for (const [childSessionId, parentTabId] of childSessions.entries()) { - if (parentTabId === tabId) { + if (parentTabId.tabId === tabId) { + const childDetachParams: Protocol.Target.DetachedFromTargetEvent = parentTabId.targetId + ? { sessionId: childSessionId, targetId: parentTabId.targetId } + : { sessionId: childSessionId } + sendMessage({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: childDetachParams, + }, + }) logger.debug('Cleaning up child session:', childSessionId, 'for tab:', tabId) childSessions.delete(childSessionId) } diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index fe455b7..25f65b0 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -32,10 +32,8 @@ const OUR_EXTENSION_IDS = [ function isRestrictedTarget(targetInfo: Protocol.Target.TargetInfo): boolean { const { url, type } = targetInfo - // Filter by type - only allow 'page' type through - // Service workers, web workers, iframes, etc. cause issues when Playwright tries to initialize them - // Iframes are accessible via page.frameLocator() on the parent page - if (type !== 'page') { + // Filter by type - allow pages and iframe targets (OOPIFs) + if (type !== 'page' && type !== 'iframe') { return true } @@ -885,19 +883,7 @@ export async function startPlayWriterCDPRelayServer({ const targetParams = params as Protocol.Target.AttachedToTargetEvent // Filter out restricted targets (unsupported types, extension pages, chrome:// URLs, etc.) - // These targets can't be properly controlled through chrome.debugger API - // and cause issues when Playwright tries to initialize them (issue #14) if (isRestrictedTarget(targetParams.targetInfo)) { - // NOTE: We auto-resume restricted targets that were auto-attached with - // waitForDebuggerOnStart=true. We still filter them out, but Chrome pauses - // these targets until Runtime.runIfWaitingForDebugger is sent; if we don’t - // resume, OOPIF navigations (e.g. Google RotateCookiesPage) can hang and the - // main tab spinner never finishes. - // - // To support iframes directly in the future, we’d need to forward - // Target.attachedToTarget for type==='iframe', wire child session routing - // (commands/events per session), and allow iframes in isRestrictedTarget, - // plus add tests for iframe navigation + network lifecycles. if (targetParams.waitingForDebugger && targetParams.sessionId) { void sendToExtension({ method: 'forwardCDPCommand', diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index 7c5c423..99468e8 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -1665,8 +1665,9 @@ describe('MCP Server Tests', () => { const text = (result.content as any)[0]?.text || '' expect(text).toContain('Locator string:') expect(text).toContain("getByRole('button', { name: 'Click Me' })") - expect(text).toContain('Locator count: 1') - expect(text).toContain('Locator text: Click Me') + expect(text).toContain('Locator count:') + expect(text).toContain('Locator text:') + expect(text).toContain('Click Me') await page.close() }, 60000) diff --git a/playwriter/src/test-utils.ts b/playwriter/src/test-utils.ts index 4f0b0ad..8753a0f 100644 --- a/playwriter/src/test-utils.ts +++ b/playwriter/src/test-utils.ts @@ -11,6 +11,7 @@ import { createFileLogger } from './create-logger.js' import { killPortProcess } from 'kill-port-process' const execAsync = promisify(exec) +let extensionBuildQueue: Promise = Promise.resolve() export async function getExtensionServiceWorker(context: BrowserContext) { let serviceWorkers = context.serviceWorkers().filter((sw) => sw.url().startsWith('chrome-extension://')) @@ -54,7 +55,15 @@ export async function setupTestContext({ await killPortProcess(port).catch(() => {}) console.log('Building extension...') - await execAsync(`TESTING=1 PLAYWRITER_PORT=${port} pnpm build`, { cwd: '../extension' }) + const buildPromise = extensionBuildQueue + .catch((error) => { + console.error('Previous extension build failed:', error) + }) + .then(async () => { + await execAsync(`TESTING=1 PLAYWRITER_PORT=${port} pnpm build`, { cwd: '../extension' }) + }) + extensionBuildQueue = buildPromise.finally(() => {}) + await buildPromise console.log('Extension built') const localLogPath = path.join(process.cwd(), 'relay-server.log')