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 { imageSize } from 'image-size'
|
||||||
import { getCDPSessionForPage } from './cdp-session.js'
|
import { getCDPSessionForPage } from './cdp-session.js'
|
||||||
|
|
||||||
|
declare const window: any
|
||||||
|
declare const document: any
|
||||||
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
@@ -1349,7 +1352,7 @@ describe('MCP Server Tests', () => {
|
|||||||
const wsUrl = getCdpUrl()
|
const wsUrl = getCdpUrl()
|
||||||
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
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 = {
|
const normalized = {
|
||||||
cssLayoutViewport: layoutMetrics.cssLayoutViewport,
|
cssLayoutViewport: layoutMetrics.cssLayoutViewport,
|
||||||
@@ -1429,7 +1432,7 @@ describe('MCP Server Tests', () => {
|
|||||||
const wsUrl = getCdpUrl()
|
const wsUrl = getCdpUrl()
|
||||||
const client = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
|
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).toBeDefined()
|
||||||
expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0)
|
expect(layoutMetrics.cssVisualViewport.clientWidth).toBeGreaterThan(0)
|
||||||
|
|
||||||
@@ -1601,12 +1604,12 @@ describe('CDP Session Tests', () => {
|
|||||||
const localVars: Record<string, unknown> = {}
|
const localVars: Record<string, unknown> = {}
|
||||||
|
|
||||||
if (localScope?.object.objectId) {
|
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,
|
objectId: localScope.object.objectId,
|
||||||
ownProperties: true,
|
ownProperties: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const prop of result) {
|
for (const prop of propsResult.result) {
|
||||||
if (prop.value) {
|
if (prop.value) {
|
||||||
localVars[prop.name] = prop.value.type === 'object'
|
localVars[prop.name] = prop.value.type === 'object'
|
||||||
? `[object ${prop.value.className || prop.value.subtype || '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,
|
callFrameId: topFrame.callFrameId,
|
||||||
expression: 'localVar + " world " + numberVar',
|
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
|
const functionNames = profile.nodes
|
||||||
.map(n => n.callFrame.functionName)
|
.map(n => n.callFrame.functionName)
|
||||||
@@ -1707,14 +1711,20 @@ describe('CDP Session Tests', () => {
|
|||||||
sampleFunctionNames: functionNames,
|
sampleFunctionNames: functionNames,
|
||||||
}).toMatchInlineSnapshot(`
|
}).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
"durationMicroseconds": 6879,
|
"durationMicroseconds": 8975,
|
||||||
"hasNodes": true,
|
"hasNodes": true,
|
||||||
"nodeCount": 5,
|
"nodeCount": 20,
|
||||||
"sampleFunctionNames": [
|
"sampleFunctionNames": [
|
||||||
"(root)",
|
"(root)",
|
||||||
"(program)",
|
"(program)",
|
||||||
"evaluate",
|
|
||||||
"(idle)",
|
"(idle)",
|
||||||
|
"evaluate",
|
||||||
|
"fibonacci",
|
||||||
|
"fibonacci",
|
||||||
|
"fibonacci",
|
||||||
|
"fibonacci",
|
||||||
|
"fibonacci",
|
||||||
|
"fibonacci",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|||||||
Reference in New Issue
Block a user