add cdp session. fix tests with it
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
Reference in New Issue
Block a user