add type-safe event emitter to relay server for CDP message interception

This commit is contained in:
Tommy D. Rossi
2025-12-19 01:39:57 +01:00
parent b7ff5c6d75
commit 71c8507b55
3 changed files with 182 additions and 18 deletions
+6
View File
@@ -48,6 +48,12 @@ export type CDPEventBase = {
export type CDPMessage = CDPCommand | CDPResponse | CDPEvent;
export type RelayServerEvents = {
'cdp:command': (data: { clientId: string; command: CDPCommand }) => void
'cdp:event': (data: { event: CDPEventBase; sessionId?: string }) => void
'cdp:response': (data: { clientId: string; response: CDPResponseBase; command: CDPCommand }) => void
}
export { Protocol, ProtocolMapping };
// types tests. to see if types are right with some simple examples
+32 -14
View File
@@ -3,9 +3,10 @@ import { serve } from '@hono/node-server'
import { createNodeWebSocket } from '@hono/node-ws'
import type { WSContext } from 'hono/ws'
import type { Protocol } from '../cdp-types.js'
import type { CDPCommand, CDPResponseBase, CDPEventBase, CDPEventFor } from '../cdp-types.js'
import type { CDPCommand, CDPResponseBase, CDPEventBase, CDPEventFor, RelayServerEvents } from '../cdp-types.js'
import type { ExtensionMessage, ExtensionEventMessage } from './protocol.js'
import chalk from 'chalk'
import { EventEmitter } from 'node:events'
type ConnectedTarget = {
sessionId: string
@@ -21,7 +22,14 @@ type PlaywrightClient = {
}
export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.0.0.1', logger }: { port?: number; host?: string; logger?: { log(...args: any[]): void; error(...args: any[]): void } } = {}) {
export type RelayServer = {
close(): void
on<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
}
export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.0.0.1', logger }: { port?: number; host?: string; logger?: { log(...args: any[]): void; error(...args: any[]): void } } = {}): Promise<RelayServer> {
const emitter = new EventEmitter()
const connectedTargets = new Map<string, ConnectedTarget>()
const playwrightClients = new Map<string, PlaywrightClient>()
@@ -314,6 +322,8 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
id
})
emitter.emit('cdp:command', { clientId, command: message })
if (!extensionWs) {
sendToPlaywright({
message: {
@@ -395,20 +405,18 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
}
}
sendToPlaywright({
message: { id, sessionId, result },
clientId
})
const response: CDPResponseBase = { id, sessionId, result }
sendToPlaywright({ message: response, clientId })
emitter.emit('cdp:response', { clientId, response, command: message })
} catch (e) {
logger?.error('Error handling CDP command:', method, params, e)
sendToPlaywright({
message: {
id,
sessionId,
error: { message: (e as Error).message }
},
clientId
})
const errorResponse: CDPResponseBase = {
id,
sessionId,
error: { message: (e as Error).message }
}
sendToPlaywright({ message: errorResponse, clientId })
emitter.emit('cdp:response', { clientId, response: errorResponse, command: message })
}
},
@@ -492,6 +500,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
params
})
const cdpEvent: CDPEventBase = { method, sessionId, params }
emitter.emit('cdp:event', { event: cdpEvent, sessionId })
if (method === 'Target.attachedToTarget') {
const targetParams = params as Protocol.Target.AttachedToTargetEvent
@@ -608,6 +619,13 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
playwrightClients.clear()
extensionWs?.close(1000, 'Server stopped')
server.close()
emitter.removeAllListeners()
},
on<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]) {
emitter.on(event, listener as (...args: unknown[]) => void)
},
off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]) {
emitter.off(event, listener as (...args: unknown[]) => void)
}
}
}
+144 -4
View File
@@ -11,8 +11,9 @@ import type { ExtensionState } from 'mcp-extension/src/types.js'
import type { Protocol } from 'devtools-protocol'
import { imageSize } from 'image-size'
import { getCDPSessionForPage } from './cdp-session.js'
import { startPlayWriterCDPRelayServer } from './extension/cdp-relay.js'
import { startPlayWriterCDPRelayServer, type RelayServer } from './extension/cdp-relay.js'
import { createFileLogger } from './create-logger.js'
import type { CDPCommand } from './cdp-types.js'
declare const window: any
declare const document: any
@@ -65,7 +66,7 @@ async function killProcessOnPort(port: number): Promise<void> {
interface TestContext {
browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>>
userDataDir: string
relayServer: { close(): void }
relayServer: RelayServer
}
async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): Promise<TestContext> {
@@ -1303,6 +1304,14 @@ describe('MCP Server Tests', () => {
await new Promise(r => setTimeout(r, 500))
const capturedCommands: CDPCommand[] = []
const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
if (command.method === 'Page.captureScreenshot') {
capturedCommands.push(command)
}
}
testCtx!.relayServer.on('cdp:command', commandHandler)
const browser = await chromium.connectOverCDP(getCdpUrl())
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
@@ -1332,6 +1341,45 @@ describe('MCP Server Tests', () => {
expect(fullPageDimensions.height).toBeGreaterThan(0)
expect(fullPageDimensions.width).toBeGreaterThanOrEqual(viewportDimensions.width!)
testCtx!.relayServer.off('cdp:command', commandHandler)
expect(capturedCommands.length).toBe(2)
expect(capturedCommands.map(c => ({
method: c.method,
params: c.params
}))).toMatchInlineSnapshot(`
[
{
"method": "Page.captureScreenshot",
"params": {
"captureBeyondViewport": false,
"clip": {
"height": 720,
"scale": 1,
"width": 1280,
"x": 0,
"y": 0,
},
"format": "png",
},
},
{
"method": "Page.captureScreenshot",
"params": {
"captureBeyondViewport": false,
"clip": {
"height": 528,
"scale": 1,
"width": 1280,
"x": 0,
"y": 0,
},
"format": "png",
},
},
]
`)
const screenshotPath = path.join(os.tmpdir(), 'playwriter-test-screenshot.png')
fs.writeFileSync(screenshotPath, viewportScreenshot)
console.log('Screenshot saved to:', screenshotPath)
@@ -1340,6 +1388,98 @@ describe('MCP Server Tests', () => {
await page.close()
}, 60000)
it('should capture element screenshot with correct coordinates', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
const target = { x: 200, y: 150, width: 300, height: 100 }
const scrolledTarget = { x: 100, y: 1500, width: 200, height: 80 }
const page = await browserContext.newPage()
await page.setContent(`
<html>
<head>
<style>
body { margin: 0; padding: 0; height: 2000px; }
#target {
position: absolute;
top: ${target.y}px;
left: ${target.x}px;
width: ${target.width}px;
height: ${target.height}px;
background: red;
}
#scrolled-target {
position: absolute;
top: ${scrolledTarget.y}px;
left: ${scrolledTarget.x}px;
width: ${scrolledTarget.width}px;
height: ${scrolledTarget.height}px;
background: blue;
}
</style>
</head>
<body>
<div id="target">Target Element</div>
<div id="scrolled-target">Scrolled Target</div>
</body>
</html>
`)
await page.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 500))
const capturedCommands: CDPCommand[] = []
const commandHandler = ({ command }: { clientId: string; command: CDPCommand }) => {
if (command.method === 'Page.captureScreenshot') {
capturedCommands.push(command)
}
}
testCtx!.relayServer.on('cdp:command', commandHandler)
const browser = await chromium.connectOverCDP(getCdpUrl())
let cdpPage
for (const p of browser.contexts()[0].pages()) {
const html = await p.content()
if (html.includes('scrolled-target')) {
cdpPage = p
break
}
}
expect(cdpPage).toBeDefined()
await cdpPage!.locator('#target').screenshot()
await cdpPage!.locator('#scrolled-target').screenshot()
testCtx!.relayServer.off('cdp:command', commandHandler)
expect(capturedCommands.length).toBe(2)
const targetCmd = capturedCommands[0]
expect(targetCmd.method).toBe('Page.captureScreenshot')
const targetClip = (targetCmd.params as any).clip
expect(targetClip.x).toBe(target.x)
expect(targetClip.y).toBe(target.y)
expect(targetClip.width).toBe(target.width)
expect(targetClip.height).toBe(target.height)
const scrolledCmd = capturedCommands[1]
expect(scrolledCmd.method).toBe('Page.captureScreenshot')
const scrolledClip = (scrolledCmd.params as any).clip
expect(scrolledClip.x).toBe(scrolledTarget.x)
expect(scrolledClip.y).toBe(scrolledTarget.y)
expect(scrolledClip.width).toBe(scrolledTarget.width)
expect(scrolledClip.height).toBe(scrolledTarget.height)
await browser.close()
await page.close()
}, 60000)
it('should get locator string for element using getLocatorStringForElement', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
@@ -1774,9 +1914,9 @@ describe('CDP Session Tests', () => {
sampleFunctionNames: functionNames,
}).toMatchInlineSnapshot(`
{
"durationMicroseconds": 12993,
"durationMicroseconds": 8646,
"hasNodes": true,
"nodeCount": 21,
"nodeCount": 20,
"sampleFunctionNames": [
"(root)",
"(program)",