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 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 };
|
export { Protocol, ProtocolMapping };
|
||||||
|
|
||||||
// types tests. to see if types are right with some simple examples
|
// 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 { createNodeWebSocket } from '@hono/node-ws'
|
||||||
import type { WSContext } from 'hono/ws'
|
import type { WSContext } from 'hono/ws'
|
||||||
import type { Protocol } from '../cdp-types.js'
|
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 type { ExtensionMessage, ExtensionEventMessage } from './protocol.js'
|
||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
|
||||||
type ConnectedTarget = {
|
type ConnectedTarget = {
|
||||||
sessionId: string
|
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 connectedTargets = new Map<string, ConnectedTarget>()
|
||||||
|
|
||||||
const playwrightClients = new Map<string, PlaywrightClient>()
|
const playwrightClients = new Map<string, PlaywrightClient>()
|
||||||
@@ -314,6 +322,8 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|||||||
id
|
id
|
||||||
})
|
})
|
||||||
|
|
||||||
|
emitter.emit('cdp:command', { clientId, command: message })
|
||||||
|
|
||||||
if (!extensionWs) {
|
if (!extensionWs) {
|
||||||
sendToPlaywright({
|
sendToPlaywright({
|
||||||
message: {
|
message: {
|
||||||
@@ -395,20 +405,18 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendToPlaywright({
|
const response: CDPResponseBase = { id, sessionId, result }
|
||||||
message: { id, sessionId, result },
|
sendToPlaywright({ message: response, clientId })
|
||||||
clientId
|
emitter.emit('cdp:response', { clientId, response, command: message })
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger?.error('Error handling CDP command:', method, params, e)
|
logger?.error('Error handling CDP command:', method, params, e)
|
||||||
sendToPlaywright({
|
const errorResponse: CDPResponseBase = {
|
||||||
message: {
|
id,
|
||||||
id,
|
sessionId,
|
||||||
sessionId,
|
error: { message: (e as Error).message }
|
||||||
error: { message: (e as Error).message }
|
}
|
||||||
},
|
sendToPlaywright({ message: errorResponse, clientId })
|
||||||
clientId
|
emitter.emit('cdp:response', { clientId, response: errorResponse, command: message })
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -492,6 +500,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|||||||
params
|
params
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const cdpEvent: CDPEventBase = { method, sessionId, params }
|
||||||
|
emitter.emit('cdp:event', { event: cdpEvent, sessionId })
|
||||||
|
|
||||||
if (method === 'Target.attachedToTarget') {
|
if (method === 'Target.attachedToTarget') {
|
||||||
const targetParams = params as Protocol.Target.AttachedToTargetEvent
|
const targetParams = params as Protocol.Target.AttachedToTargetEvent
|
||||||
|
|
||||||
@@ -608,6 +619,13 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|||||||
playwrightClients.clear()
|
playwrightClients.clear()
|
||||||
extensionWs?.close(1000, 'Server stopped')
|
extensionWs?.close(1000, 'Server stopped')
|
||||||
server.close()
|
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 type { Protocol } from 'devtools-protocol'
|
||||||
import { imageSize } from 'image-size'
|
import { imageSize } from 'image-size'
|
||||||
import { getCDPSessionForPage } from './cdp-session.js'
|
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 { createFileLogger } from './create-logger.js'
|
||||||
|
import type { CDPCommand } from './cdp-types.js'
|
||||||
|
|
||||||
declare const window: any
|
declare const window: any
|
||||||
declare const document: any
|
declare const document: any
|
||||||
@@ -65,7 +66,7 @@ async function killProcessOnPort(port: number): Promise<void> {
|
|||||||
interface TestContext {
|
interface TestContext {
|
||||||
browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>>
|
browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>>
|
||||||
userDataDir: string
|
userDataDir: string
|
||||||
relayServer: { close(): void }
|
relayServer: RelayServer
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): Promise<TestContext> {
|
async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): Promise<TestContext> {
|
||||||
@@ -1303,6 +1304,14 @@ describe('MCP Server Tests', () => {
|
|||||||
|
|
||||||
await new Promise(r => setTimeout(r, 500))
|
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 browser = await chromium.connectOverCDP(getCdpUrl())
|
||||||
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
|
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.height).toBeGreaterThan(0)
|
||||||
expect(fullPageDimensions.width).toBeGreaterThanOrEqual(viewportDimensions.width!)
|
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')
|
const screenshotPath = path.join(os.tmpdir(), 'playwriter-test-screenshot.png')
|
||||||
fs.writeFileSync(screenshotPath, viewportScreenshot)
|
fs.writeFileSync(screenshotPath, viewportScreenshot)
|
||||||
console.log('Screenshot saved to:', screenshotPath)
|
console.log('Screenshot saved to:', screenshotPath)
|
||||||
@@ -1340,6 +1388,98 @@ describe('MCP Server Tests', () => {
|
|||||||
await page.close()
|
await page.close()
|
||||||
}, 60000)
|
}, 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 () => {
|
it('should get locator string for element using getLocatorStringForElement', async () => {
|
||||||
const browserContext = getBrowserContext()
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
@@ -1774,9 +1914,9 @@ describe('CDP Session Tests', () => {
|
|||||||
sampleFunctionNames: functionNames,
|
sampleFunctionNames: functionNames,
|
||||||
}).toMatchInlineSnapshot(`
|
}).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
"durationMicroseconds": 12993,
|
"durationMicroseconds": 8646,
|
||||||
"hasNodes": true,
|
"hasNodes": true,
|
||||||
"nodeCount": 21,
|
"nodeCount": 20,
|
||||||
"sampleFunctionNames": [
|
"sampleFunctionNames": [
|
||||||
"(root)",
|
"(root)",
|
||||||
"(program)",
|
"(program)",
|
||||||
|
|||||||
Reference in New Issue
Block a user