add cdp session. fix tests with it

This commit is contained in:
Tommy D. Rossi
2025-12-16 19:46:38 +01:00
parent d593e20a3c
commit 0a36692371
2 changed files with 161 additions and 9 deletions
+142
View File
@@ -0,0 +1,142 @@
import WebSocket from 'ws'
import type { Page } from 'playwright-core'
import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'
import type { CDPResponseBase, CDPEventBase } from './cdp-types.js'
interface PendingRequest {
resolve: (result: unknown) => void
reject: (error: Error) => void
}
export class CDPSession {
private ws: WebSocket
private pendingRequests = new Map<number, PendingRequest>()
private eventListeners = new Map<string, Set<(params: unknown) => void>>()
private messageId = 0
private sessionId: string | null = null
constructor(ws: WebSocket) {
this.ws = ws
this.ws.on('message', (data) => {
const message = JSON.parse(data.toString()) as CDPResponseBase | CDPEventBase
if ('id' in message) {
const response = message as CDPResponseBase
const pending = this.pendingRequests.get(response.id)
if (pending) {
this.pendingRequests.delete(response.id)
if (response.error) {
pending.reject(new Error(response.error.message))
} else {
pending.resolve(response.result)
}
}
} else if ('method' in message) {
const event = message as CDPEventBase
if (event.sessionId === this.sessionId || !event.sessionId) {
const listeners = this.eventListeners.get(event.method)
if (listeners) {
for (const listener of listeners) {
listener(event.params)
}
}
}
}
})
}
setSessionId(sessionId: string) {
this.sessionId = sessionId
}
send<K extends keyof ProtocolMapping.Commands>(
method: K,
params?: ProtocolMapping.Commands[K]['paramsType'][0]
): Promise<ProtocolMapping.Commands[K]['returnType']> {
const id = ++this.messageId
const message: Record<string, unknown> = { id, method, params }
if (this.sessionId) {
message.sessionId = this.sessionId
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(id)
reject(new Error(`CDP command timeout: ${method}`))
}, 30000)
this.pendingRequests.set(id, {
resolve: (result) => {
clearTimeout(timeout)
resolve(result as ProtocolMapping.Commands[K]['returnType'])
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
}
})
this.ws.send(JSON.stringify(message))
})
}
on<K extends keyof ProtocolMapping.Events>(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set())
}
this.eventListeners.get(event)!.add(callback as (params: unknown) => void)
}
off<K extends keyof ProtocolMapping.Events>(event: K, callback: (params: ProtocolMapping.Events[K][0]) => void) {
this.eventListeners.get(event)?.delete(callback as (params: unknown) => void)
}
detach() {
for (const pending of this.pendingRequests.values()) {
pending.reject(new Error('CDPSession detached'))
}
this.pendingRequests.clear()
this.eventListeners.clear()
this.ws.close()
}
}
export async function getCDPSessionForPage({ page, wsUrl }: { page: Page; wsUrl: string }): Promise<CDPSession> {
const ws = new WebSocket(wsUrl)
await new Promise<void>((resolve, reject) => {
ws.on('open', resolve)
ws.on('error', reject)
})
const cdp = new CDPSession(ws)
const pages = page.context().pages()
const pageIndex = pages.indexOf(page)
if (pageIndex === -1) {
cdp.detach()
throw new Error('Page not found in context')
}
const { targetInfos } = await cdp.send('Target.getTargets')
const pageTargets = targetInfos.filter(t => t.type === 'page')
if (pageIndex >= pageTargets.length) {
cdp.detach()
throw new Error(`Page index ${pageIndex} out of bounds (${pageTargets.length} targets)`)
}
const target = pageTargets[pageIndex]
if (target.url !== page.url()) {
cdp.detach()
throw new Error(`URL mismatch: page has "${page.url()}" but target has "${target.url}"`)
}
const { sessionId } = await cdp.send('Target.attachToTarget', {
targetId: target.targetId,
flatten: true
})
cdp.setSessionId(sessionId)
return cdp
}
+19 -9
View File
@@ -12,6 +12,9 @@ import type { Protocol } from 'devtools-protocol'
import { imageSize } from 'image-size'
import { getCDPSessionForPage } from './cdp-session.js'
declare const window: any
declare const document: any
const execAsync = promisify(exec)
@@ -1349,7 +1352,7 @@ describe('MCP Server Tests', () => {
const wsUrl = getCdpUrl()
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
const layoutMetrics = await cdpSession.send<Protocol.Page.GetLayoutMetricsResponse>('Page.getLayoutMetrics')
const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics')
const normalized = {
cssLayoutViewport: layoutMetrics.cssLayoutViewport,
@@ -1429,7 +1432,7 @@ describe('MCP Server Tests', () => {
const wsUrl = getCdpUrl()
const client = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
const layoutMetrics = await client.send<Protocol.Page.GetLayoutMetricsResponse>('Page.getLayoutMetrics')
const layoutMetrics = await client.send('Page.getLayoutMetrics')
expect(layoutMetrics.cssVisualViewport).toBeDefined()
expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0)
@@ -1601,12 +1604,12 @@ describe('CDP Session Tests', () => {
const localVars: Record<string, unknown> = {}
if (localScope?.object.objectId) {
const { result } = await cdpSession.send<{ result: Protocol.Runtime.PropertyDescriptor[] }>('Runtime.getProperties', {
const propsResult = await cdpSession.send('Runtime.getProperties', {
objectId: localScope.object.objectId,
ownProperties: true,
})
for (const prop of result) {
for (const prop of propsResult.result) {
if (prop.value) {
localVars[prop.name] = prop.value.type === 'object'
? `[object ${prop.value.className || prop.value.subtype || 'Object'}]`
@@ -1632,7 +1635,7 @@ describe('CDP Session Tests', () => {
}
`)
const evalResult = await cdpSession.send<{ result: Protocol.Runtime.RemoteObject }>('Debugger.evaluateOnCallFrame', {
const evalResult = await cdpSession.send('Debugger.evaluateOnCallFrame', {
callFrameId: topFrame.callFrameId,
expression: 'localVar + " world " + numberVar',
})
@@ -1693,7 +1696,8 @@ describe('CDP Session Tests', () => {
})()
`)
const { profile } = await cdpSession.send<{ profile: Protocol.Profiler.Profile }>('Profiler.stop')
const stopResult = await cdpSession.send('Profiler.stop')
const profile = stopResult.profile
const functionNames = profile.nodes
.map(n => n.callFrame.functionName)
@@ -1707,14 +1711,20 @@ describe('CDP Session Tests', () => {
sampleFunctionNames: functionNames,
}).toMatchInlineSnapshot(`
{
"durationMicroseconds": 6879,
"durationMicroseconds": 8975,
"hasNodes": true,
"nodeCount": 5,
"nodeCount": 20,
"sampleFunctionNames": [
"(root)",
"(program)",
"evaluate",
"(idle)",
"evaluate",
"fibonacci",
"fibonacci",
"fibonacci",
"fibonacci",
"fibonacci",
"fibonacci",
],
}
`)