From 50b453a64c0ea4cd198f04c2e5a3b7b44f0b7c6d Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 14 Nov 2025 14:35:48 +0100 Subject: [PATCH] nicer typescript types --- extension-playwright/package.json | 5 +- extension-playwright/src/relayConnection.ts | 29 ++-- plan.md | 9 +- playwriter/package.json | 1 + playwriter/src/cdp-types.ts | 124 ++++++++++++++++++ playwriter/src/extension/cdpRelay.ts | 81 +++++------- .../src/extension/extensionContextFactory.ts | 20 +-- playwriter/src/extension/protocol.ts | 46 ++++--- playwriter/src/mcp.ts | 2 +- pnpm-lock.yaml | 12 ++ 10 files changed, 229 insertions(+), 100 deletions(-) create mode 100644 playwriter/src/cdp-types.ts diff --git a/extension-playwright/package.json b/extension-playwright/package.json index 633930d..d4fa511 100644 --- a/extension-playwright/package.json +++ b/extension-playwright/package.json @@ -23,9 +23,12 @@ }, "devDependencies": { "@types/chrome": "^0.0.315", - "typescript": "^5.8.2", "concurrently": "^8.2.2", + "typescript": "^5.8.2", "vite": "^5.4.21", "vite-plugin-static-copy": "^3.1.1" + }, + "dependencies": { + "playwriter": "workspace:*" } } diff --git a/extension-playwright/src/relayConnection.ts b/extension-playwright/src/relayConnection.ts index b203af4..b7bf028 100644 --- a/extension-playwright/src/relayConnection.ts +++ b/extension-playwright/src/relayConnection.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +import type { Protocol } from 'playwriter/src/cdp-types'; +import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/extension/protocol'; + export function debugLog(...args: unknown[]): void { const enabled = true; if (enabled) { @@ -22,20 +25,6 @@ export function debugLog(...args: unknown[]): void { } } -type ProtocolCommand = { - id: number; - method: string; - params?: any; -}; - -type ProtocolResponse = { - id?: number; - method?: string; - params?: any; - result?: any; - error?: string; -}; - export class RelayConnection { private _debuggee: chrome.debugger.Debuggee; private _ws: WebSocket; @@ -110,7 +99,7 @@ export class RelayConnection { } private async _onMessageAsync(event: MessageEvent): Promise { - let message: ProtocolCommand; + let message: ExtensionCommandMessage; try { message = JSON.parse(event.data); } catch (error: any) { @@ -121,7 +110,7 @@ export class RelayConnection { debugLog('Received message:', message); - const response: ProtocolResponse = { + const response: ExtensionResponseMessage = { id: message.id, }; try { @@ -134,12 +123,12 @@ export class RelayConnection { this._sendMessage(response); } - private async _handleCommand(message: ProtocolCommand): Promise { + private async _handleCommand(message: ExtensionCommandMessage): Promise { if (message.method === 'attachToTab') { await this._tabPromise; debugLog('Attaching debugger to tab:', this._debuggee); await chrome.debugger.attach(this._debuggee, '1.3'); - const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo'); + const result = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo') as Protocol.Target.GetTargetInfoResponse; return { targetInfo: result?.targetInfo, }; @@ -147,13 +136,13 @@ export class RelayConnection { if (!this._debuggee.tabId) throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.'); if (message.method === 'forwardCDPCommand') { - const { sessionId, method, params } = message.params; + const forwardParams = message.params as { sessionId?: string; method: string; params?: any }; + const { sessionId, method, params } = forwardParams; debugLog('CDP command:', method, params); const debuggerSession: chrome.debugger.DebuggerSession = { ...this._debuggee, sessionId, }; - // Forward CDP command to chrome.debugger return await chrome.debugger.sendCommand( debuggerSession, method, diff --git a/plan.md b/plan.md index 8844d3c..9868662 100644 --- a/plan.md +++ b/plan.md @@ -1 +1,8 @@ -now see where attachToTab is sent and received. see that we basically attach to the tab as soon as the playwright instance connects. I want to change this: instead attach to the tab when we click on the extension icon, meaning inside onClicked. when this happens we should also send also a cdp message from the extension + +currently the extension and server code assume there is only one tab attached to debugger only. i want instead to be able to manage multiple tabs. we should update all classes fields that manage tab state to a array {tabId, dsessionId, targetId}[], then we should send relevant commands to the right chrome.debugger debugee tab, finding the right tab for a specific CDP command (based on sessionId or targetId) + +--- + +now see where attachToTab is sent and received. see that we basically attach to the tab as soon as the playwright instance connects. I want to change this: instead attach to the tab when we click on the extension icon, meaning inside onClicked. when this happens we should also send also a cdp message from the extension Target.attachedToTarget after we are connected. so playwright knows of this new page + +now see where we already are sending Target.attachedToTarget. this is now sent setAutoAttach, meaning as soon as playwright connects. let's change this. we should not do anything during setAutoAttach, leave it empty for now. diff --git a/playwriter/package.json b/playwriter/package.json index 028cdde..71c11c0 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.21.1", + "devtools-protocol": "^0.0.1543509", "playwright-core": "^1.56.1", "user-agents": "^1.1.669", "ws": "^8.18.3", diff --git a/playwriter/src/cdp-types.ts b/playwriter/src/cdp-types.ts new file mode 100644 index 0000000..fbdd893 --- /dev/null +++ b/playwriter/src/cdp-types.ts @@ -0,0 +1,124 @@ +import type { Protocol } from 'devtools-protocol'; +import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'; + +export type CDPCommand = { + id: number; + sessionId?: string; + method: T; + params?: ProtocolMapping.Commands[T]['paramsType'][0]; +}; + +export type CDPResponse = { + id: number; + sessionId?: string; + result?: ProtocolMapping.Commands[T]['returnType']; + error?: { code?: number; message: string }; +}; + +export type CDPEvent = { + method: T; + sessionId?: string; + params?: ProtocolMapping.Events[T][0]; +}; + +export type CDPMessage = CDPCommand | CDPResponse | CDPEvent; + +export { Protocol, ProtocolMapping }; + +// types tests. to see if types are right with some simple examples +if (false as any) { + const browserVersionCommand = { + id: 1, + method: 'Browser.getVersion', + } satisfies CDPCommand; + + const browserVersionResponse = { + id: 1, + result: { + protocolVersion: '1.3', + product: 'Chrome', + revision: '123', + userAgent: 'Mozilla/5.0', + jsVersion: 'V8', + } + } satisfies CDPResponse; + + const targetAttachCommand = { + id: 2, + method: 'Target.setAutoAttach', + params: { + autoAttach: true, + waitForDebuggerOnStart: false, + } + } satisfies CDPCommand; + + const targetAttachResponse = { + id: 2, + result: undefined, + } satisfies CDPResponse; + + const attachedToTargetEvent = { + method: 'Target.attachedToTarget', + params: { + sessionId: 'session-1', + targetInfo: { + targetId: 'target-1', + type: 'page', + title: 'Example', + url: 'https://example.com', + attached: true, + canAccessOpener: false, + }, + waitingForDebugger: false, + } + } satisfies CDPEvent; + + const consoleMessageEvent = { + method: 'Runtime.consoleAPICalled', + params: { + type: 'log', + args: [], + executionContextId: 1, + timestamp: 123456789, + } + } satisfies CDPEvent; + + const pageNavigateCommand = { + id: 3, + method: 'Page.navigate', + params: { + url: 'https://example.com', + } + } satisfies CDPCommand; + + const pageNavigateResponse = { + id: 3, + result: { + frameId: 'frame-1', + } + } satisfies CDPResponse; + + const networkRequestEvent = { + method: 'Network.requestWillBeSent', + sessionId: 'session-1', + params: { + requestId: 'req-1', + loaderId: 'loader-1', + documentURL: 'https://example.com', + request: { + url: 'https://example.com/api', + method: 'GET', + headers: {}, + initialPriority: 'High', + referrerPolicy: 'no-referrer', + }, + timestamp: 123456789, + wallTime: 123456789, + initiator: { + type: 'other', + }, + redirectHasExtraInfo: false, + type: 'XHR', + } + } satisfies CDPEvent; +} diff --git a/playwriter/src/extension/cdpRelay.ts b/playwriter/src/extension/cdpRelay.ts index bf48104..112b445 100644 --- a/playwriter/src/extension/cdpRelay.ts +++ b/playwriter/src/extension/cdpRelay.ts @@ -34,27 +34,12 @@ import { ManualPromise } from 'playwright-core/lib/utils'; import type { WebSocket, WebSocketServer } from 'playwright-core/lib/utilsBundle'; import type websocket from 'ws'; -import type { ExtensionCommand, ExtensionEvents } from './protocol.js'; +import type { ExtensionCommandMessage, ExtensionEventMessage, ExtensionMessage } from './protocol.js'; +import type { CDPCommand, CDPResponse, CDPEvent, Protocol } from '../cdp-types.js'; const debugLogger = debug('pw:mcp:relay'); -type CDPCommand = { - id: number; - sessionId?: string; - method: string; - params?: any; -}; - -type CDPResponse = { - id?: number; - sessionId?: string; - method?: string; - params?: any; - result?: any; - error?: { code?: number; message: string }; -}; - export class CDPRelayServer { private _wsHost: string; @@ -180,16 +165,14 @@ export class CDPRelayServer { this._extensionConnectionPromise.resolve(); } - private _handleExtensionMessage(method: M, params: ExtensionEvents[M]['params']) { - switch (method) { - case 'forwardCDPEvent': - const sessionId = params.sessionId || this._connectedTabInfo?.sessionId; - this._sendToPlaywright({ - sessionId, - method: params.method, - params: params.params - }); - break; + private _handleExtensionMessage(message: ExtensionEventMessage) { + if (message.method === 'forwardCDPEvent') { + const sessionId = message.params.sessionId || this._connectedTabInfo?.sessionId; + this._sendToPlaywright({ + sessionId, + method: message.params.method, + params: message.params.params + } as CDPEvent); } } @@ -215,8 +198,10 @@ export class CDPRelayServer { return { protocolVersion: '1.3', product: 'Chrome/Extension-Bridge', + revision: '1.0.0', userAgent: 'CDP-Bridge-Server/1.0.0', - }; + jsVersion: 'V8', + } satisfies Protocol.Browser.GetVersionResponse; } case 'Browser.setDownloadBehavior': { return { }; @@ -228,7 +213,7 @@ export class CDPRelayServer { // Simulate auto-attach behavior with real target info if (!this._extensionConnection) throw new Error('Extension not connected. Please ensure the Chrome extension is installed and connected to the extension endpoint before connecting Playwright.'); - const { targetInfo } = await this._extensionConnection.send('attachToTab', { }); + const { targetInfo } = await this._extensionConnection.send({ method: 'attachToTab' }); this._connectedTabInfo = { targetInfo, sessionId: `pw-tab-${this._nextSessionId++}`, @@ -244,7 +229,7 @@ export class CDPRelayServer { }, waitingForDebugger: false } - }); + } satisfies CDPEvent); return { }; } case 'Target.getTargetInfo': { @@ -260,29 +245,27 @@ export class CDPRelayServer { // Top level sessionId is only passed between the relay and the client. if (this._connectedTabInfo?.sessionId === sessionId) sessionId = undefined; - return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params }); + return await this._extensionConnection.send({ + method: 'forwardCDPCommand', + params: { sessionId, method, params } + }); } - private _sendToPlaywright(message: CDPResponse): void { - debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`); + private _sendToPlaywright(message: CDPResponse | CDPEvent): void { + const logMessage = 'method' in message && message.method + ? message.method + : `response(id=${'id' in message ? message.id : 'unknown'})`; + debugLogger('→ Playwright:', logMessage); this._playwrightConnection?.send(JSON.stringify(message)); } } -type ExtensionResponse = { - id?: number; - method?: string; - params?: any; - result?: any; - error?: string; -}; - class ExtensionConnection { private readonly _ws: WebSocket; private readonly _callbacks = new Map void, reject: (e: Error) => void, error: Error }>(); private _lastId = 0; - onmessage?: (method: M, params: ExtensionEvents[M]['params']) => void; + onmessage?: (message: ExtensionEventMessage) => void; onclose?: (self: ExtensionConnection, reason: string) => void; constructor(ws: WebSocket) { @@ -292,12 +275,12 @@ class ExtensionConnection { this._ws.on('error', this._onError.bind(this)); } - async send(method: M, params: ExtensionCommand[M]['params']): Promise { + async send(command: Omit): Promise { if (this._ws.readyState !== ws.OPEN) throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`); const id = ++this._lastId; - this._ws.send(JSON.stringify({ id, method, params })); - const error = new Error(`Protocol error: ${method}`); + this._ws.send(JSON.stringify({ id, ...command })); + const error = new Error(`Protocol error: ${command.method}`); return new Promise((resolve, reject) => { this._callbacks.set(id, { resolve, reject, error }); }); @@ -327,8 +310,8 @@ class ExtensionConnection { } } - private _handleParsedMessage(object: ExtensionResponse) { - if (object.id && this._callbacks.has(object.id)) { + private _handleParsedMessage(object: ExtensionMessage) { + if ('id' in object && this._callbacks.has(object.id)) { const callback = this._callbacks.get(object.id)!; this._callbacks.delete(object.id); if (object.error) { @@ -338,10 +321,10 @@ class ExtensionConnection { } else { callback.resolve(object.result); } - } else if (object.id) { + } else if ('id' in object) { debugLogger('← Extension: unexpected response', object); } else { - this.onmessage?.(object.method! as keyof ExtensionEvents, object.params); + this.onmessage?.(object as ExtensionEventMessage); } } diff --git a/playwriter/src/extension/extensionContextFactory.ts b/playwriter/src/extension/extensionContextFactory.ts index b13710c..cfb4a1b 100644 --- a/playwriter/src/extension/extensionContextFactory.ts +++ b/playwriter/src/extension/extensionContextFactory.ts @@ -27,7 +27,7 @@ import { BrowserContextFactory, ClientInfo } from './types.js'; const debugLogger = debug('pw:mcp:relay'); -export async function createExtensionContext(abortSignal: AbortSignal, ): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { +export async function startRelayServer(abortSignal: AbortSignal, ){ // Merge obtainBrowser into this function const httpServer = await startHttpServer({ port: 9988 }); if (abortSignal.aborted) { @@ -41,16 +41,16 @@ export async function createExtensionContext(abortSignal: AbortSignal, ): Promis // TODO simply call fetch. it was creatign a full fledged browser just to fetch an url previously. CRAZY // await cdpRelayServer.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName); // await waitForExtension() - return {} - const browser = await playwright.chromium.connectOverCDP(cdpRelayServer.cdpEndpoint()); + return { cdpRelayServer } + // const browser = await playwright.chromium.connectOverCDP(cdpRelayServer.cdpEndpoint()); - return { - browserContext: browser.contexts()[0], - close: async () => { - debugLogger('close() called for browser context'); - await browser.close(); - } - }; + // return { + // browserContext: browser.contexts()[0], + // close: async () => { + // debugLogger('close() called for browser context'); + // await browser.close(); + // } + // }; } diff --git a/playwriter/src/extension/protocol.ts b/playwriter/src/extension/protocol.ts index 7b0cb26..846b59c 100644 --- a/playwriter/src/extension/protocol.ts +++ b/playwriter/src/extension/protocol.ts @@ -18,25 +18,35 @@ // extension version should be compatible with the old MCP clients. export const VERSION = 1; -export type ExtensionCommand = { - 'attachToTab': { - params: {}; - }; - 'forwardCDPCommand': { - params: { - method: string, - sessionId?: string - params?: any, +export type ExtensionCommandMessage = + | { + id: number; + method: 'attachToTab'; + params?: {}; + } + | { + id: number; + method: 'forwardCDPCommand'; + params: { + method: string; + sessionId?: string; + params?: any; + }; }; + +export type ExtensionResponseMessage = { + id: number; + result?: any; + error?: string; +}; + +export type ExtensionEventMessage = { + method: 'forwardCDPEvent'; + params: { + method: string; + sessionId?: string; + params?: any; }; }; -export type ExtensionEvents = { - 'forwardCDPEvent': { - params: { - method: string, - sessionId?: string - params?: any, - }; - }; -}; +export type ExtensionMessage = ExtensionResponseMessage | ExtensionEventMessage; diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index 18fc960..440dc05 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -1,7 +1,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' -import { Page, Browser, BrowserContext, chromium } from 'patchright-core' +import { Page, Browser, BrowserContext, chromium } from 'playwright-core' import fs from 'node:fs' import path from 'node:path' import os from 'node:os' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dd9aea..59d6d74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,10 @@ importers: version: 4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(tsx@4.20.6) extension-playwright: + dependencies: + playwriter: + specifier: workspace:* + version: link:../playwriter devDependencies: '@types/chrome': specifier: ^0.0.315 @@ -52,6 +56,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.21.1 version: 1.21.1 + devtools-protocol: + specifier: ^0.0.1543509 + version: 0.0.1543509 playwright-core: specifier: ^1.56.1 version: 1.56.1 @@ -803,6 +810,9 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + devtools-protocol@0.0.1543509: + resolution: {integrity: sha512-JlHmIPtgDeqcL2uwPA7xryDNSb1ug9h11IexYQUG98vcyV6/L3taLRECgUk/B/7yQhXC/sgBzKj9kaB+bxmdWg==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -2310,6 +2320,8 @@ snapshots: detect-indent@6.1.0: {} + devtools-protocol@0.0.1543509: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0