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.
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## 0.0.69
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Playwriter",
|
"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.",
|
"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"],
|
"permissions": ["debugger", "tabGroups", "contextMenus", "tabs", "tabCapture", "offscreen", "identity", "identity.email"],
|
||||||
"host_permissions": ["<all_urls>"],
|
"host_permissions": ["<all_urls>"],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-extension",
|
"name": "mcp-extension",
|
||||||
"version": "0.0.69",
|
"version": "0.0.71",
|
||||||
"description": "Playwright MCP Browser Extension",
|
"description": "Playwright MCP Browser Extension",
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -859,6 +859,13 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise<any> {
|
|||||||
if (!debuggee) {
|
if (!debuggee) {
|
||||||
throw new Error(`No debuggee found for Runtime.enable (sessionId: ${msg.params.sessionId})`)
|
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.
|
// 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
|
// 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.
|
// Runtime.executionContextCreated events - those were already sent to the first client.
|
||||||
@@ -866,12 +873,12 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise<any> {
|
|||||||
// re-enable, ensuring the new client receives them. The relay server waits for the
|
// re-enable, ensuring the new client receives them. The relay server waits for the
|
||||||
// executionContextCreated events before returning. See cdp-timing.md for details.
|
// executionContextCreated events before returning. See cdp-timing.md for details.
|
||||||
try {
|
try {
|
||||||
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
|
await chrome.debugger.sendCommand(runtimeSession, 'Runtime.disable')
|
||||||
await sleep(50)
|
await sleep(50)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Error disabling Runtime (ignoring):', 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': {
|
case 'Target.createTarget': {
|
||||||
|
|||||||
@@ -171,6 +171,122 @@ describe('Relay Navigation Tests', () => {
|
|||||||
}
|
}
|
||||||
}, 60000)
|
}, 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': '<!doctype html><html><body><button id="login-btn">Login</button></body></html>',
|
||||||
|
'/canvas.html': '<!doctype html><html><body><button id="canvas-btn">Canvas</button></body></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.
|
||||||
|
'/': `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<iframe id="plugin-frame"></iframe>
|
||||||
|
<script>
|
||||||
|
window.startPluginFlow = () => {
|
||||||
|
const frame = document.getElementById('plugin-frame');
|
||||||
|
frame.src = '${loginUrl}';
|
||||||
|
setTimeout(() => {
|
||||||
|
frame.src = '${canvasUrl}';
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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 () => {
|
it('should have non-empty URLs when connecting to already-loaded pages', async () => {
|
||||||
const _browserContext = getBrowserContext()
|
const _browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(_browserContext)
|
const serviceWorker = await getExtensionServiceWorker(_browserContext)
|
||||||
|
|||||||
Reference in New Issue
Block a user