From 71c8507b559d4dee74be5718dae22e97ed2e6606 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 19 Dec 2025 01:39:57 +0100 Subject: [PATCH] add type-safe event emitter to relay server for CDP message interception --- playwriter/src/cdp-types.ts | 6 ++ playwriter/src/extension/cdp-relay.ts | 46 +++++--- playwriter/src/mcp.test.ts | 148 +++++++++++++++++++++++++- 3 files changed, 182 insertions(+), 18 deletions(-) diff --git a/playwriter/src/cdp-types.ts b/playwriter/src/cdp-types.ts index 2f044d8..6b8e477 100644 --- a/playwriter/src/cdp-types.ts +++ b/playwriter/src/cdp-types.ts @@ -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 diff --git a/playwriter/src/extension/cdp-relay.ts b/playwriter/src/extension/cdp-relay.ts index deb27d3..89b900a 100644 --- a/playwriter/src/extension/cdp-relay.ts +++ b/playwriter/src/extension/cdp-relay.ts @@ -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(event: K, listener: RelayServerEvents[K]): void + off(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 { + const emitter = new EventEmitter() const connectedTargets = new Map() const playwrightClients = new Map() @@ -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(event: K, listener: RelayServerEvents[K]) { + emitter.on(event, listener as (...args: unknown[]) => void) + }, + off(event: K, listener: RelayServerEvents[K]) { + emitter.off(event, listener as (...args: unknown[]) => void) } } } diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index 133c75a..5fcf6d7 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -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 { interface TestContext { browserContext: Awaited> userDataDir: string - relayServer: { close(): void } + relayServer: RelayServer } async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): Promise { @@ -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(` + + + + + +
Target Element
+
Scrolled Target
+ + + `) + 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)",