From e6f1e8c089de548a0b0f9eb90d94466f33f88d40 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Wed, 11 Feb 2026 16:15:02 +0100 Subject: [PATCH] fix extension runtime session routing for oopif iframe locators Route Runtime.enable/disable through the incoming child sessionId instead of always using the tab root session so OOPIF iframe targets receive execution contexts reliably. Add a focused relay regression test for empty-src cross-origin iframe attach flow and update extension version/changelog for release tracking. --- extension/CHANGELOG.md | 6 ++ extension/manifest.json | 2 +- extension/package.json | 2 +- extension/src/background.ts | 11 ++- playwriter/src/relay-navigation.test.ts | 116 ++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 4 deletions(-) diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index 1c99c0b..3f427f3 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.0.71 + +### Bug Fixes + +- **Route Runtime.enable to child CDP sessions**: Runtime enable/disable now uses the incoming `sessionId` when targeting OOPIF child sessions instead of always using the tab root session. This fixes missing `Runtime.executionContextCreated` events for child iframe targets, which could cause iframe locator operations to hang. + ## 0.0.69 ### Features diff --git a/extension/manifest.json b/extension/manifest.json index 37bcc24..9dcdeaf 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Playwriter", - "version": "0.0.70", + "version": "0.0.71", "description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.", "permissions": ["debugger", "tabGroups", "contextMenus", "tabs", "tabCapture", "offscreen", "identity", "identity.email"], "host_permissions": [""], diff --git a/extension/package.json b/extension/package.json index 87981a0..f360b32 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "mcp-extension", - "version": "0.0.69", + "version": "0.0.71", "description": "Playwright MCP Browser Extension", "private": true, "repository": { diff --git a/extension/src/background.ts b/extension/src/background.ts index b869aaf..4548626 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -859,6 +859,13 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise { if (!debuggee) { throw new Error(`No debuggee found for Runtime.enable (sessionId: ${msg.params.sessionId})`) } + // Keep Runtime.enable bound to the incoming child sessionId for OOPIF iframes. + // If we send Runtime.enable on the tab root session, child iframe targets never + // emit Runtime.executionContextCreated and frame locators can hang. + const runtimeSession: chrome.debugger.DebuggerSession = { + ...debuggee, + sessionId: msg.params.sessionId !== targetTab?.sessionId ? msg.params.sessionId : undefined, + } // When multiple Playwright clients connect to the same tab, each calls Runtime.enable. // If Runtime is already enabled, the enable call succeeds but Chrome doesn't re-send // Runtime.executionContextCreated events - those were already sent to the first client. @@ -866,12 +873,12 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise { // re-enable, ensuring the new client receives them. The relay server waits for the // executionContextCreated events before returning. See cdp-timing.md for details. try { - await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') + await chrome.debugger.sendCommand(runtimeSession, 'Runtime.disable') await sleep(50) } catch (e) { logger.debug('Error disabling Runtime (ignoring):', e) } - return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', msg.params.params) + return await chrome.debugger.sendCommand(runtimeSession, 'Runtime.enable', msg.params.params) } case 'Target.createTarget': { diff --git a/playwriter/src/relay-navigation.test.ts b/playwriter/src/relay-navigation.test.ts index f2f31da..d6124fd 100644 --- a/playwriter/src/relay-navigation.test.ts +++ b/playwriter/src/relay-navigation.test.ts @@ -171,6 +171,122 @@ describe('Relay Navigation Tests', () => { } }, 60000) + it('should resolve locators for cross-origin iframe that starts with empty src', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const childServer = await createSimpleServer({ + routes: { + '/login.html': '', + '/canvas.html': '', + }, + }) + const loginUrl = `${childServer.baseUrl}/login.html` + const canvasUrl = `${childServer.baseUrl}/canvas.html` + + const parentServer = await createSimpleServer({ + routes: { + // Reproduces Framer-like plugin iframes: attached with empty src first, + // then navigated cross-origin after auto-attach is active. + '/': ` + + + + + +`, + }, + }) + + const page = await browserContext.newPage() + try { + await withTimeout({ + promise: page.goto(parentServer.baseUrl, { waitUntil: 'domcontentloaded', timeout: 5000 }), + timeoutMs: 6000, + errorMessage: 'Timed out loading parent page for empty-src iframe test', + }) + await page.bringToFront() + + await withTimeout({ + promise: serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }), + timeoutMs: 5000, + errorMessage: 'Timed out toggling extension for empty-src iframe test', + }) + + const browser = await withTimeout({ + promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })), + timeoutMs: 5000, + errorMessage: 'Timed out connecting over CDP for empty-src iframe test', + }) + + try { + const context = browser.contexts()[0] + const cdpPage = context.pages().find((candidate) => { + return candidate.url().startsWith(parentServer.baseUrl) + }) + expect(cdpPage).toBeDefined() + + await withTimeout({ + promise: page.evaluate(() => { + ;(window as Window & { startPluginFlow?: () => void }).startPluginFlow?.() + }), + timeoutMs: 3000, + errorMessage: 'Timed out starting plugin iframe flow', + }) + + const pluginFrame = await withTimeout({ + promise: (async () => { + for (let attempt = 0; attempt < 40; attempt += 1) { + const frame = cdpPage!.frames().find((candidate) => { + return candidate.url() === loginUrl || candidate.url() === canvasUrl + }) + if (frame) { + return frame + } + await cdpPage!.waitForTimeout(100) + } + throw new Error('Plugin frame did not appear with expected URL') + })(), + timeoutMs: 5000, + errorMessage: 'Timed out waiting for plugin frame URL in empty-src iframe test', + }) + + const buttonCount = await withTimeout({ + promise: pluginFrame.locator('button').count(), + timeoutMs: 5000, + errorMessage: 'Timed out counting button locator in empty-src iframe test', + }) + expect(buttonCount).toBe(1) + } finally { + await withTimeout({ + promise: browser.close(), + timeoutMs: 5000, + errorMessage: 'Timed out closing CDP browser for empty-src iframe test', + }) + } + } finally { + await withTimeout({ + promise: page.close(), + timeoutMs: 5000, + errorMessage: 'Timed out closing page for empty-src iframe test', + }) + await Promise.all([ + parentServer.close(), + childServer.close(), + ]) + } + }, 60000) + it('should have non-empty URLs when connecting to already-loaded pages', async () => { const _browserContext = getBrowserContext() const serviceWorker = await getExtensionServiceWorker(_browserContext)