diff --git a/playwriter/src/cdp-session.ts b/playwriter/src/cdp-session.ts new file mode 100644 index 0000000..0a16de2 --- /dev/null +++ b/playwriter/src/cdp-session.ts @@ -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() + private eventListeners = new Map 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( + method: K, + params?: ProtocolMapping.Commands[K]['paramsType'][0] + ): Promise { + const id = ++this.messageId + const message: Record = { 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(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(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 { + const ws = new WebSocket(wsUrl) + + await new Promise((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 +} diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index e5dad9f..fc43f66 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -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('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('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 = {} 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", ], } `)