From 581f283cb845ad8d663f74e617570327e53b9d33 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 22 Dec 2025 18:11:23 +0100 Subject: [PATCH] update cdp relay state on page navigation --- playwriter/src/cdp-session.ts | 8 +- playwriter/src/extension/cdp-relay.ts | 60 +++++++++ playwriter/src/mcp.test.ts | 178 +++++++++++++++++++++++++- 3 files changed, 235 insertions(+), 11 deletions(-) diff --git a/playwriter/src/cdp-session.ts b/playwriter/src/cdp-session.ts index 2c71fa9..9f5014e 100644 --- a/playwriter/src/cdp-session.ts +++ b/playwriter/src/cdp-session.ts @@ -101,7 +101,7 @@ export class CDPSession { this.eventListeners.get(event)?.delete(callback as (params: unknown) => void) } - detach() { + close() { try { for (const pending of this.pendingRequests.values()) { pending.reject(new Error('CDPSession detached')) @@ -128,7 +128,7 @@ export async function getCDPSessionForPage({ page, wsUrl }: { page: Page; wsUrl: const pages = page.context().pages() const pageIndex = pages.indexOf(page) if (pageIndex === -1) { - cdp.detach() + cdp.close() throw new Error('Page not found in context') } @@ -136,13 +136,13 @@ export async function getCDPSessionForPage({ page, wsUrl }: { page: Page; wsUrl: const pageTargets = targetInfos.filter((t) => t.type === 'page') if (pageIndex >= pageTargets.length) { - cdp.detach() + cdp.close() throw new Error(`Page index ${pageIndex} out of bounds (${pageTargets.length} targets)`) } const target = pageTargets[pageIndex] if (target.url !== page.url()) { - cdp.detach() + cdp.close() throw new Error(`URL mismatch: page has "${page.url()}" but target has "${target.url}"`) } diff --git a/playwriter/src/extension/cdp-relay.ts b/playwriter/src/extension/cdp-relay.ts index 45b2bbf..1fe2da3 100644 --- a/playwriter/src/extension/cdp-relay.ts +++ b/playwriter/src/extension/cdp-relay.ts @@ -544,6 +544,23 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127. } as CDPEventBase, source: 'extension' }) + } else if (method === 'Target.targetCrashed') { + const crashParams = params as Protocol.Target.TargetCrashedEvent + for (const [sid, target] of connectedTargets.entries()) { + if (target.targetId === crashParams.targetId) { + connectedTargets.delete(sid) + logger?.log(chalk.red('[Server] Target crashed, removing:'), crashParams.targetId) + break + } + } + + sendToPlaywright({ + message: { + method: 'Target.targetCrashed', + params: crashParams + } as CDPEventBase, + source: 'extension' + }) } else if (method === 'Target.targetInfoChanged') { const infoParams = params as Protocol.Target.TargetInfoChangedEvent for (const target of connectedTargets.values()) { @@ -560,6 +577,49 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127. } as CDPEventBase, source: 'extension' }) + } else if (method === 'Page.frameNavigated') { + const frameParams = params as Protocol.Page.FrameNavigatedEvent + if (!frameParams.frame.parentId && sessionId) { + const target = connectedTargets.get(sessionId) + if (target) { + target.targetInfo = { + ...target.targetInfo, + url: frameParams.frame.url, + title: frameParams.frame.name || target.targetInfo.title, + } + logger?.log(chalk.magenta('[Server] Updated target URL from Page.frameNavigated:'), frameParams.frame.url) + } + } + + sendToPlaywright({ + message: { + sessionId, + method, + params + } as CDPEventBase, + source: 'extension' + }) + } else if (method === 'Page.navigatedWithinDocument') { + const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent + if (sessionId) { + const target = connectedTargets.get(sessionId) + if (target) { + target.targetInfo = { + ...target.targetInfo, + url: navParams.url, + } + logger?.log(chalk.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'), navParams.url) + } + } + + sendToPlaywright({ + message: { + sessionId, + method, + params + } as CDPEventBase, + source: 'extension' + }) } else { sendToPlaywright({ message: { diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index 483eec6..3158e34 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -1606,7 +1606,7 @@ describe('MCP Server Tests', () => { console.log('window.devicePixelRatio:', windowDpr) expect(windowDpr).toBe(1) - cdpSession.detach() + cdpSession.close() await browser.close() await page.close() }, 60000) @@ -1636,7 +1636,7 @@ describe('MCP Server Tests', () => { expect(layoutMetrics.cssVisualViewport).toBeDefined() expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0) - client.detach() + client.close() await browser.close() await page.close() }, 60000) @@ -1764,6 +1764,12 @@ describe('CDP Session Tests', () => { beforeAll(async () => { testCtx = await setupTestContext({ tempDirPrefix: 'pw-cdp-test-' }) + + const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext) + await serviceWorker.evaluate(async () => { + await globalThis.disconnectEverything() + }) + await new Promise(r => setTimeout(r, 500)) }, 600000) afterAll(async () => { @@ -1907,7 +1913,7 @@ describe('CDP Session Tests', () => { await cdpSession.send('Debugger.resume') await cdpSession.send('Debugger.disable') - cdpSession.detach() + cdpSession.close() await browser.close() await page.close() }, 60000) @@ -1964,21 +1970,179 @@ describe('CDP Session Tests', () => { sampleFunctionNames: functionNames, }).toMatchInlineSnapshot(` { - "durationMicroseconds": 6962, + "durationMicroseconds": 9014, "hasNodes": true, - "nodeCount": 8, + "nodeCount": 20, "sampleFunctionNames": [ "(root)", "(program)", "(idle)", "evaluate", - "querySelectorAll", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", ], } `) await cdpSession.send('Profiler.disable') - cdpSession.detach() + cdpSession.close() + await browser.close() + await page.close() + }, 60000) + + it('should update Target.getTargets URL after page navigation', 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, 500)) + + const browser = await chromium.connectOverCDP(getCdpUrl()) + const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) + expect(cdpPage).toBeDefined() + + const wsUrl = getCdpUrl() + const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) + + const initialTargets = await cdpSession.send('Target.getTargets') + const initialPageTarget = initialTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('example.com')) + expect(initialPageTarget?.url).toBe('https://example.com/') + + await cdpPage!.goto('https://news.ycombinator.com/', { waitUntil: 'networkidle' }) + await new Promise(r => setTimeout(r, 500)) + + const afterNavTargets = await cdpSession.send('Target.getTargets') + const allPageTargets = afterNavTargets.targetInfos.filter(t => t.type === 'page') + + const aboutBlankTargets = allPageTargets.filter(t => t.url === 'about:blank') + expect(aboutBlankTargets).toHaveLength(0) + + const exampleComTargets = allPageTargets.filter(t => t.url.includes('example.com')) + expect(exampleComTargets).toHaveLength(0) + + const hnTargets = allPageTargets.filter(t => t.url.includes('news.ycombinator.com')) + expect(hnTargets).toHaveLength(1) + + cdpSession.close() + await browser.close() + await page.close() + }, 60000) + + it('should return correct targets for multiple pages via Target.getTargets', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const page1 = await browserContext.newPage() + await page1.goto('https://example.com/') + await page1.bringToFront() + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + const page2 = await browserContext.newPage() + await page2.goto('https://news.ycombinator.com/') + await page2.bringToFront() + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + await new Promise(r => setTimeout(r, 500)) + + const browser = await chromium.connectOverCDP(getCdpUrl()) + const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) + expect(cdpPage).toBeDefined() + + const wsUrl = getCdpUrl() + const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) + + const { targetInfos } = await cdpSession.send('Target.getTargets') + const allPageTargets = targetInfos.filter(t => t.type === 'page') + + const aboutBlankTargets = allPageTargets.filter(t => t.url === 'about:blank') + expect(aboutBlankTargets).toHaveLength(0) + + const pageTargets = allPageTargets + .map(t => ({ type: t.type, url: t.url })) + .sort((a, b) => a.url.localeCompare(b.url)) + + expect(pageTargets).toMatchInlineSnapshot(` + [ + { + "type": "page", + "url": "https://example.com/", + }, + { + "type": "page", + "url": "https://news.ycombinator.com/", + }, + ] + `) + + cdpSession.close() + await browser.close() + await page1.close() + await page2.close() + }, 60000) + + it('should maintain CDP session functionality after page URL change', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const page = await browserContext.newPage() + const initialUrl = 'https://example.com/' + await page.goto(initialUrl) + await page.bringToFront() + + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise(r => setTimeout(r, 500)) + + const browser = await chromium.connectOverCDP(getCdpUrl()) + const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) + expect(cdpPage).toBeDefined() + + const wsUrl = getCdpUrl() + const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) + + const initialEvalResult = await cdpSession.send('Runtime.evaluate', { + expression: 'document.title', + returnByValue: true, + }) + expect(initialEvalResult.result.value).toBe('Example Domain') + + const newUrl = 'https://news.ycombinator.com/' + await cdpPage!.goto(newUrl, { waitUntil: 'networkidle' }) + + expect(cdpPage!.url()).toBe(newUrl) + + const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics') + expect(layoutMetrics.cssVisualViewport).toBeDefined() + expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0) + + const afterNavEvalResult = await cdpSession.send('Runtime.evaluate', { + expression: 'document.title', + returnByValue: true, + }) + expect(afterNavEvalResult.result.value).toContain('Hacker News') + + const locationResult = await cdpSession.send('Runtime.evaluate', { + expression: 'window.location.href', + returnByValue: true, + }) + expect(locationResult.result.value).toBe(newUrl) + + cdpSession.close() await browser.close() await page.close() }, 60000)