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.
This commit is contained in:
Tommy D. Rossi
2026-03-12 16:30:44 +01:00
parent df7476af06
commit 4557fe16f9
4 changed files with 283 additions and 2 deletions
+18
View File
@@ -1,5 +1,23 @@
# Changelog # 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 ## 0.0.85
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "playwriter", "name": "playwriter",
"description": "", "description": "",
"version": "0.0.85", "version": "0.0.88",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
+110
View File
@@ -83,6 +83,7 @@ export async function startPlayWriterCDPRelayServer({
} = {}): Promise<RelayServer> { } = {}): Promise<RelayServer> {
const emitter = new EventEmitter() const emitter = new EventEmitter()
const store = relayState.createRelayStore() const store = relayState.createRelayStore()
const extensionDownloadBehavior = new Map<string, Protocol.Browser.SetDownloadBehaviorRequest>()
const resolvedCdpLogger = cdpLogger || createCdpLogger() const resolvedCdpLogger = cdpLogger || createCdpLogger()
const logCdpJson = (entry: CdpLogEntry) => { 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<void> {
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({ async function routeCdpCommand({
extensionId, extensionId,
method, method,
@@ -585,6 +672,18 @@ export async function startPlayWriterCDPRelayServer({
} }
case 'Browser.setDownloadBehavior': { 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 {} return {}
} }
@@ -1336,6 +1435,8 @@ export async function startPlayWriterCDPRelayServer({
const cdpEvent: CDPEventBase = { method, sessionId, params } const cdpEvent: CDPEventBase = { method, sessionId, params }
emitter.emit('cdp:event', { event: cdpEvent, sessionId }) emitter.emit('cdp:event', { event: cdpEvent, sessionId })
maybeEmitBrowserDownloadCompatEvent({ method, params, extensionId: connectionId })
if (method === 'Target.attachedToTarget') { if (method === 'Target.attachedToTarget') {
const targetParams = params as Protocol.Target.AttachedToTargetEvent const targetParams = params as Protocol.Target.AttachedToTargetEvent
const incomingSessionId = sessionId 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 // Only forward to Playwright if this is a new target to avoid duplicates
if (!alreadyConnected) { if (!alreadyConnected) {
sendToPlaywright({ sendToPlaywright({
+154 -1
View File
@@ -1,7 +1,9 @@
import { createMCPClient } from './mcp-client.js' import { createMCPClient } from './mcp-client.js'
import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { chromium } from '@xmorse/playwright-core'
import { getCDPSessionForPage } from './cdp-session.js' 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 { import {
setupTestContext, setupTestContext,
cleanupTestContext, cleanupTestContext,
@@ -121,6 +123,157 @@ describe('Relay Core Tests', () => {
await page.close() await page.close()
}, 60000) }, 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: {
'/': `<!doctype html>
<html>
<body>
<button id="download-button">Download</button>
<script>
const button = document.getElementById('download-button');
button.addEventListener('click', () => {
const blob = new Blob(['playwriter-download-test'], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'playwriter-download-test.txt';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
});
</script>
</body>
</html>`,
},
})
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 () => { it('should execute code and capture console output', async () => {
await client.callTool({ await client.callTool({
name: 'execute', name: 'execute',