add type-safe event emitter to relay server for CDP message interception
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user