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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -83,6 +83,7 @@ export async function startPlayWriterCDPRelayServer({
|
||||
} = {}): Promise<RelayServer> {
|
||||
const emitter = new EventEmitter()
|
||||
const store = relayState.createRelayStore()
|
||||
const extensionDownloadBehavior = new Map<string, Protocol.Browser.SetDownloadBehaviorRequest>()
|
||||
|
||||
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<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({
|
||||
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({
|
||||
|
||||
@@ -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: {
|
||||
'/': `<!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 () => {
|
||||
await client.callTool({
|
||||
name: 'execute',
|
||||
|
||||
Reference in New Issue
Block a user