From 4557fe16f9b01e574465e15a24e326f8df5192b8 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Thu, 12 Mar 2026 16:30:44 +0100 Subject: [PATCH] fix(relay): restore extension-mode downloads via Browser/Page compatibility bridge (fixes #65) Map Browser.setDownloadBehavior to per-page Page.setDownloadBehavior in the relay and cache behavior per extension so newly attached pages inherit the same download policy. Forward compatibility download events in both namespaces by emitting synthetic root-session Browser.downloadWillBegin/Browser.downloadProgress while preserving original Page.download* events for backward compatibility clients. Add an integration regression test that reproduces the extension-mode download path, verifies Playwright download event resolution, and snapshots observed CDP method coverage from cdp.jsonl. --- playwriter/CHANGELOG.md | 18 ++++ playwriter/package.json | 2 +- playwriter/src/cdp-relay.ts | 110 +++++++++++++++++++++ playwriter/src/relay-core.test.ts | 155 +++++++++++++++++++++++++++++- 4 files changed, 283 insertions(+), 2 deletions(-) diff --git a/playwriter/CHANGELOG.md b/playwriter/CHANGELOG.md index 758b74c..65401cc 100644 --- a/playwriter/CHANGELOG.md +++ b/playwriter/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.0.88 + +### Improvements + +- **Inline download behavior normalization in relay**: Removed the standalone `toPageDownloadBehavior` helper and inlined the `allowAndName -> allow` mapping at the call site to keep the download-behavior forwarding path more direct. + +## 0.0.87 + +### Improvements + +- **Simplify relay download compatibility path**: Extracted Browser-download compatibility emission into a focused helper and simplified download-behavior mapping logic in the relay, while keeping dual `Page.download*` + `Browser.download*` forwarding behavior and improving test assertions to include `Page.setDownloadBehavior` forwarding. + +## 0.0.86 + +### Bug Fixes + +- **Fix extension-mode downloads for Playwright clients**: Relay now translates `Browser.setDownloadBehavior` into per-target `Page.setDownloadBehavior`, caches the behavior for future page targets, and emits both `Page.download*` and synthetic root-session `Browser.download*` events so `page.waitForEvent('download')` works while preserving backward compatibility for clients that still rely on `Page.downloadWillBegin`/`Page.downloadProgress`. + ## 0.0.85 ### Bug Fixes diff --git a/playwriter/package.json b/playwriter/package.json index 9224d48..d061527 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -1,7 +1,7 @@ { "name": "playwriter", "description": "", - "version": "0.0.85", + "version": "0.0.88", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index d31b809..742e574 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -83,6 +83,7 @@ export async function startPlayWriterCDPRelayServer({ } = {}): Promise { const emitter = new EventEmitter() const store = relayState.createRelayStore() + const extensionDownloadBehavior = new Map() const resolvedCdpLogger = cdpLogger || createCdpLogger() const logCdpJson = (entry: CdpLogEntry) => { @@ -557,6 +558,92 @@ export async function startPlayWriterCDPRelayServer({ } } + function getPageTargetSessionIds({ extensionId }: { extensionId: string }): string[] { + const extensionState = store.getState().extensions.get(extensionId) + if (!extensionState) { + return [] + } + return Array.from(extensionState.connectedTargets.values()) + .filter((target) => { + return target.targetInfo.type === 'page' + }) + .map((target) => { + return target.sessionId + }) + } + + function maybeEmitBrowserDownloadCompatEvent({ + method, + params, + extensionId, + }: { + method: string + params: unknown + extensionId: string + }): void { + const browserEventMethod = + method === 'Page.downloadWillBegin' + ? 'Browser.downloadWillBegin' + : method === 'Page.downloadProgress' + ? 'Browser.downloadProgress' + : null + if (!browserEventMethod) { + return + } + sendToPlaywright({ + message: { + method: browserEventMethod, + params, + } as CDPEventBase, + source: 'server', + extensionId, + }) + } + + async function applyDownloadBehaviorToTargets({ + extensionId, + behavior, + source, + targetSessionIds, + }: { + extensionId: string + behavior: Protocol.Browser.SetDownloadBehaviorRequest + source?: CDPCommand['source'] + targetSessionIds?: string[] + }): Promise { + const pageBehavior: Protocol.Page.SetDownloadBehaviorRequest['behavior'] = + behavior.behavior === 'allowAndName' ? 'allow' : behavior.behavior + const pageParams: Protocol.Page.SetDownloadBehaviorRequest = (() => { + if (pageBehavior === 'allow' && behavior.downloadPath) { + return { behavior: pageBehavior, downloadPath: behavior.downloadPath } + } + return { behavior: pageBehavior } + })() + const sessions = targetSessionIds || getPageTargetSessionIds({ extensionId }) + if (sessions.length === 0) { + return + } + await Promise.all( + sessions.map(async (targetSessionId) => { + try { + await sendToExtension({ + extensionId, + method: 'forwardCDPCommand', + params: { + sessionId: targetSessionId, + method: 'Page.setDownloadBehavior', + params: pageParams, + source, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger?.log(pc.yellow(`[Server] Failed to apply Page.setDownloadBehavior to ${targetSessionId}: ${message}`)) + } + }), + ) + } + async function routeCdpCommand({ extensionId, method, @@ -585,6 +672,18 @@ export async function startPlayWriterCDPRelayServer({ } case 'Browser.setDownloadBehavior': { + const downloadBehaviorParams = params as Protocol.Browser.SetDownloadBehaviorRequest | undefined + if (!downloadBehaviorParams?.behavior) { + throw new Error('behavior is required for Browser.setDownloadBehavior') + } + if (resolvedExtensionId) { + extensionDownloadBehavior.set(resolvedExtensionId, downloadBehaviorParams) + await applyDownloadBehaviorToTargets({ + extensionId: resolvedExtensionId, + behavior: downloadBehaviorParams, + source, + }) + } return {} } @@ -1336,6 +1435,8 @@ export async function startPlayWriterCDPRelayServer({ const cdpEvent: CDPEventBase = { method, sessionId, params } emitter.emit('cdp:event', { event: cdpEvent, sessionId }) + maybeEmitBrowserDownloadCompatEvent({ method, params, extensionId: connectionId }) + if (method === 'Target.attachedToTarget') { const targetParams = params as Protocol.Target.AttachedToTargetEvent const incomingSessionId = sessionId @@ -1396,6 +1497,15 @@ export async function startPlayWriterCDPRelayServer({ }), ) + const cachedDownloadBehavior = extensionDownloadBehavior.get(connectionId) + if (cachedDownloadBehavior && targetParams.targetInfo.type === 'page') { + void applyDownloadBehaviorToTargets({ + extensionId: connectionId, + behavior: cachedDownloadBehavior, + targetSessionIds: [targetParams.sessionId], + }) + } + // Only forward to Playwright if this is a new target to avoid duplicates if (!alreadyConnected) { sendToPlaywright({ diff --git a/playwriter/src/relay-core.test.ts b/playwriter/src/relay-core.test.ts index 10365ab..26248a6 100644 --- a/playwriter/src/relay-core.test.ts +++ b/playwriter/src/relay-core.test.ts @@ -1,7 +1,9 @@ import { createMCPClient } from './mcp-client.js' import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { chromium } from '@xmorse/playwright-core' import { getCDPSessionForPage } from './cdp-session.js' -import { getCdpUrl } from './utils.js' +import { getCdpUrl, LOG_CDP_FILE_PATH } from './utils.js' +import fs from 'node:fs' import { setupTestContext, cleanupTestContext, @@ -121,6 +123,157 @@ describe('Relay Core Tests', () => { await page.close() }, 60000) + it('should emit download events for both Browser and Page domains in extension mode', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + const logFilePath = LOG_CDP_FILE_PATH + const logLineCountBefore = fs.existsSync(logFilePath) + ? fs + .readFileSync(logFilePath, 'utf-8') + .split('\n') + .filter((line) => { + return line.trim().length > 0 + }).length + : 0 + + const server = await createSimpleServer({ + routes: { + '/': ` + + + + + +`, + }, + }) + + const page = await browserContext.newPage() + await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' }) + await page.bringToFront() + + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + + const directBrowser = await withTimeout({ + promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })), + timeoutMs: 10000, + errorMessage: 'Timed out connecting over CDP for download reproduction test', + }) + + const connectedPage = directBrowser + .contexts()[0] + .pages() + .find((candidatePage) => { + return candidatePage.url() === server.baseUrl + '/' + }) + if (!connectedPage) { + throw new Error('Connected page not found for download reproduction test') + } + + const downloadResult = await Promise.all([ + connectedPage.waitForEvent('download', { timeout: 3000 }).then( + (download) => { + return { timedOut: false, suggestedFilename: download.suggestedFilename() } + }, + (error: Error) => { + return { timedOut: true, errorMessage: error.message } + }, + ), + connectedPage.click('#download-button'), + ]) + + expect(downloadResult[0]).toMatchInlineSnapshot(` + { + "suggestedFilename": "playwriter-download-test.txt", + "timedOut": false, + } + `) + + await directBrowser.close() + await page.close() + await server.close() + + const logLinesAfter = fs + .readFileSync(logFilePath, 'utf-8') + .split('\n') + .filter((line) => { + return line.trim().length > 0 + }) + .slice(logLineCountBefore) + + const newEntries = logLinesAfter + .map((line) => { + return tryJsonParse(line) + }) + .filter((entry): entry is { direction: string; message: { method?: string } } => { + return Boolean(entry && typeof entry === 'object' && 'direction' in entry && 'message' in entry) + }) + + const methods = newEntries + .map((entry) => { + return { + direction: entry.direction, + method: typeof entry.message?.method === 'string' ? entry.message.method : 'response', + } + }) + .filter((entry) => { + return ( + entry.method.includes('download') || + entry.method === 'Browser.setDownloadBehavior' || + entry.method === 'Page.setDownloadBehavior' + ) + }) + + const summary = { + hasBrowserSetDownloadBehavior: methods.some((entry) => { + return entry.direction === 'from-playwright' && entry.method === 'Browser.setDownloadBehavior' + }), + hasPageSetDownloadBehavior: methods.some((entry) => { + return entry.direction === 'to-extension' && entry.method === 'Page.setDownloadBehavior' + }), + hasPageDownloadWillBegin: methods.some((entry) => { + return entry.method === 'Page.downloadWillBegin' + }), + hasPageDownloadProgress: methods.some((entry) => { + return entry.method === 'Page.downloadProgress' + }), + hasBrowserDownloadWillBegin: methods.some((entry) => { + return entry.method === 'Browser.downloadWillBegin' + }), + hasBrowserDownloadProgress: methods.some((entry) => { + return entry.method === 'Browser.downloadProgress' + }), + } + + expect(summary).toMatchInlineSnapshot(` + { + "hasBrowserDownloadProgress": false, + "hasBrowserDownloadWillBegin": false, + "hasBrowserSetDownloadBehavior": true, + "hasPageDownloadProgress": false, + "hasPageDownloadWillBegin": false, + "hasPageSetDownloadBehavior": true, + } + `) + }, 120000) + it('should execute code and capture console output', async () => { await client.callTool({ name: 'execute',