diff --git a/extension/src/relayConnection.ts b/extension/src/relayConnection.ts index 28d6e02..54705b3 100644 --- a/extension/src/relayConnection.ts +++ b/extension/src/relayConnection.ts @@ -72,11 +72,6 @@ interface AttachedTab { targetId: string; sessionId: string; targetInfo: Protocol.Target.TargetInfo; - // Cache execution contexts for this tab. When Playwright reconnects and calls Runtime.enable, - // Chrome's debugger does NOT re-send Runtime.executionContextCreated events for contexts that - // already exist. We must manually replay them so Playwright knows what contexts are available. - // Without this, page.evaluate() hangs because Playwright has no valid execution context IDs. - executionContexts: Map; } export class RelayConnection { @@ -180,7 +175,6 @@ export class RelayConnection { targetId: targetInfo.targetId, sessionId, targetInfo, - executionContexts: new Map() }); logger.debug('Sending Target.attachedToTarget event, WebSocket state:', this._ws.readyState); @@ -291,18 +285,6 @@ export class RelayConnection { // Track execution contexts so we can replay them when Playwright reconnects. // Chrome's debugger only sends Runtime.executionContextCreated events once per context, // not on every Runtime.enable call. We cache them here and replay on reconnection. - if (method === 'Runtime.executionContextCreated') { - const contextEvent = params as Protocol.Runtime.ExecutionContextCreatedEvent; - tab.executionContexts.set(contextEvent.context.id, contextEvent); - logger.debug('Cached execution context:', contextEvent.context.id, 'for tab:', source.tabId, 'total contexts:', tab.executionContexts.size); - } else if (method === 'Runtime.executionContextDestroyed') { - const destroyedEvent = params as Protocol.Runtime.ExecutionContextDestroyedEvent; - tab.executionContexts.delete(destroyedEvent.executionContextId); - logger.debug('Removed execution context:', destroyedEvent.executionContextId, 'from tab:', source.tabId, 'remaining:', tab.executionContexts.size); - } else if (method === 'Runtime.executionContextsCleared') { - tab.executionContexts.clear(); - logger.debug('Cleared all execution contexts for tab:', source.tabId); - } logger.debug('Forwarding CDP event:', method, 'from tab:', source.tabId); @@ -401,34 +383,25 @@ export class RelayConnection { sessionId: msg.params.sessionId !== targetTab.sessionId ? msg.params.sessionId : undefined, }; + if (msg.params.method === 'Runtime.enable') { + logger.debug('Runtime.enable called, disabling first to force context refresh for tab:', targetTab.debuggee.tabId); + try { + await chrome.debugger.sendCommand(debuggerSession, 'Runtime.disable'); + await new Promise(resolve => setTimeout(resolve, 50)); + } catch (e) { + logger.debug('Error disabling Runtime (ignoring):', e); + } + } + const result = await chrome.debugger.sendCommand( debuggerSession, msg.params.method, msg.params.params ); - // When Playwright reconnects and calls Runtime.enable, Chrome does NOT automatically - // re-send Runtime.executionContextCreated events for contexts that already exist. - // This causes page.evaluate() to hang because Playwright has no execution context IDs. - // Solution: manually replay all cached contexts so Playwright gets fresh, valid IDs. - if (msg.params.method === 'Runtime.enable' && targetTab.executionContexts.size > 0) { - logger.debug('Runtime.enable called, replaying', targetTab.executionContexts.size, 'cached execution contexts for tab:', targetTab.debuggee.tabId); - - for (const contextEvent of targetTab.executionContexts.values()) { - logger.debug('Replaying execution context:', contextEvent.context.id); - this._sendMessage({ - method: 'forwardCDPEvent', - params: { - sessionId: msg.params.sessionId, - method: 'Runtime.executionContextCreated', - params: contextEvent, - }, - }); - } - } - return result; } + } private _sendMessage(message: any): void { @@ -437,10 +410,12 @@ export class RelayConnection { this._ws.send(JSON.stringify(message)); // logger.debug('Message sent successfully, type:', message.method || 'response'); } catch (error: any) { - logger.debug('ERROR sending message:', error, 'message type:', message.method || 'response'); + // Use console directly to avoid infinite recursion if logger tries to send log over this same connection + console.debug('ERROR sending message:', error, 'message type:', message.method || 'response'); } } else { - logger.debug('Cannot send message, WebSocket not open. State:', this._ws.readyState, 'message type:', message.method || 'response'); + // Use console directly to avoid infinite recursion + console.debug('Cannot send message, WebSocket not open. State:', this._ws.readyState, 'message type:', message.method || 'response'); } } } diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index 7668959..c3b03a9 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -311,6 +311,11 @@ describe('MCP Server Tests', () => { expect(foundPage).toBeDefined() expect(foundPage?.url()).toBe(testUrl) + // Verify execution works + const sum1 = await foundPage?.evaluate(() => 1 + 1) + expect(sum1).toBe(2) + + await directBrowser.close() // 4. Disable extension on this tab @@ -330,6 +335,7 @@ describe('MCP Server Tests', () => { foundPage = pages.find(p => p.url() === testUrl) expect(foundPage).toBeUndefined() + await directBrowser.close() // 6. Re-enable extension @@ -355,7 +361,11 @@ describe('MCP Server Tests', () => { expect(foundPage).toBeDefined() expect(foundPage?.url()).toBe(testUrl) + // Verify execution works again + const sum2 = await foundPage?.evaluate(() => 2 + 2) + expect(sum2).toBe(4) + await directBrowser.close() await page.close() }) @@ -391,6 +401,10 @@ describe('MCP Server Tests', () => { expect(foundPage).toBeDefined() expect(foundPage?.url()).toBe(testUrl) + // Verify execution works + const sum1 = await foundPage?.evaluate(() => 10 + 20) + expect(sum1).toBe(30) + // 4. Disable extension await serviceWorker.evaluate(async () => { await globalThis.toggleExtensionForActiveTab() @@ -420,6 +434,10 @@ describe('MCP Server Tests', () => { expect(foundPage).toBeDefined() expect(foundPage?.url()).toBe(testUrl) + // Verify execution works again + const sum2 = await foundPage?.evaluate(() => 30 + 40) + expect(sum2).toBe(70) + await page.close() await directBrowser.close() }) @@ -444,12 +462,18 @@ describe('MCP Server Tests', () => { const connectedPage = directBrowser.contexts()[0].pages().find(p => p.url() === initialUrl) expect(connectedPage).toBeDefined() + // Verify execution + expect(await connectedPage?.evaluate(() => 1 + 1)).toBe(2) + // 4. Reload // We use a loop to check if it's still connected because reload might cause temporary disconnect/reconnect events // that Playwright handles natively if the session ID stays valid. await connectedPage?.reload() await connectedPage?.waitForLoadState('networkidle') expect(await connectedPage?.title()).toBe('Example Domain') + + // Verify execution after reload + expect(await connectedPage?.evaluate(() => 2 + 2)).toBe(4) // 5. Navigate to new URL const newUrl = 'https://news.ycombinator.com/' @@ -459,7 +483,10 @@ describe('MCP Server Tests', () => { expect(connectedPage?.url()).toBe(newUrl) expect(await connectedPage?.title()).toContain('Hacker News') + // Verify execution after navigation + expect(await connectedPage?.evaluate(() => 3 + 3)).toBe(6) + await directBrowser.close() await page.close() }) @@ -537,6 +564,100 @@ describe('MCP Server Tests', () => { ] `) + // Verify execution on both pages + const pageA_CDP = pages.find(p => p.url().includes('tab-a')) + const pageB_CDP = pages.find(p => p.url().includes('tab-b')) + + expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20) + expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40) + + await browser.close() + await pageA.close() + await pageB.close() + }) + + it('should support multiple concurrent tabs', async () => { + if (!browserContext) throw new Error('Browser not initialized') + const serviceWorker = await getExtensionServiceWorker(browserContext) + await new Promise(resolve => setTimeout(resolve, 500)) + + // Tab A + const pageA = await browserContext.newPage() + await pageA.goto('https://example.com/tab-a') + await pageA.bringToFront() + await new Promise(resolve => setTimeout(resolve, 500)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + // Tab B + const pageB = await browserContext.newPage() + await pageB.goto('https://example.com/tab-b') + await pageB.bringToFront() + await new Promise(resolve => setTimeout(resolve, 500)) + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + // Get target IDs for both + const targetIds = await serviceWorker.evaluate(async () => { + const state = globalThis.getExtensionState() + const chrome = globalThis.chrome + const tabs = await chrome.tabs.query({}) + const tabA = tabs.find((t: any) => t.url?.includes('tab-a')) + const tabB = tabs.find((t: any) => t.url?.includes('tab-b')) + return { + idA: state.connectedTabs.get(tabA?.id)?.targetId, + idB: state.connectedTabs.get(tabB?.id)?.targetId + } + }) + + expect(targetIds).toMatchInlineSnapshot({ + idA: expect.any(String), + idB: expect.any(String) + }, ` + { + "idA": Any, + "idB": Any, + } + `) + expect(targetIds.idA).not.toBe(targetIds.idB) + + // Verify independent connections + const browser = await chromium.connectOverCDP(getCdpUrl()) + + const pages = browser.contexts()[0].pages() + + const results = await Promise.all(pages.map(async (p) => ({ + url: p.url(), + title: await p.title() + }))) + + expect(results).toMatchInlineSnapshot(` + [ + { + "title": "", + "url": "about:blank", + }, + { + "title": "Example Domain", + "url": "https://example.com/tab-a", + }, + { + "title": "Example Domain", + "url": "https://example.com/tab-b", + }, + ] + `) + + // Verify execution on both pages + const pageA_CDP = pages.find(p => p.url().includes('tab-a')) + const pageB_CDP = pages.find(p => p.url().includes('tab-b')) + + expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20) + expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40) + + await browser.close() await pageA.close() await pageB.close() })