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
|
# 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,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",
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user