From b23f10178b9e01ef95e94940ba686fb03fc7697c Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 6 Apr 2026 18:08:24 -0700 Subject: [PATCH 1/4] feat(mcp): support multiple tabs in extension cdpRelay Bumps the extension protocol to v2 and replaces the single-tab attachToTab/forwardCDPCommand interface with thin chrome.* RPC wrappers (chrome.debugger.attach/detach/sendCommand, chrome.tabs.create) plus extension.selectTab for the initial tab pick. The relay now owns all CDP session orchestration: it assigns a relay sessionId per tab, dispatches Target.attachedToTarget/detachedFromTarget to Playwright, and handles Target.createTarget by creating a chrome tab via the extension. Popups opened by a controlled tab arrive as chrome.tabs.onCreated events and are auto-attached. --- .../playwright-core/src/tools/mcp/cdpRelay.ts | 162 ++++++++++++++---- .../playwright-core/src/tools/mcp/protocol.ts | 82 ++++++++- 2 files changed, 207 insertions(+), 37 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index b2a21680510f3..56a9e83c1c7b1 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -19,7 +19,13 @@ * * Endpoints: * - /cdp/guid - Full CDP interface for Playwright MCP - * - /extension/guid - Extension connection for chrome.debugger forwarding + * - /extension/guid - Extension connection that exposes a thin chrome.* RPC. + * + * The relay owns CDP session management: it asks the extension for the user's + * tab pick (extension.selectTab), then attaches the debugger and dispatches + * Target.attachedToTarget events to Playwright. Additional tabs are created + * either by Playwright (Target.createTarget → chrome.tabs.create) or by the + * controlled tabs themselves (chrome.tabs.onCreated event from the extension). */ import { spawn } from 'child_process'; @@ -57,6 +63,12 @@ type CDPResponse = { error?: { code?: number; message: string }; }; +type TabSession = { + tabId: number; + sessionId: string; + targetInfo: any; +}; + export class CDPRelayServer { private _wsHost: string; private _browserChannel: string; @@ -67,11 +79,9 @@ export class CDPRelayServer { private _wss: WebSocketServer; private _playwrightConnection: WebSocket | null = null; private _extensionConnection: ExtensionConnection | null = null; - private _connectedTabInfo: { - targetInfo: any; - // Page sessionId that should be used by this connection. - sessionId: string; - } | undefined; + // sessionId → TabSession (sessions known to the Playwright client). + private _tabSessions = new Map(); + private _tabIdToSessionId = new Map(); private _nextSessionId: number = 1; private _extensionConnectionPromise!: ManualPromise; @@ -213,7 +223,8 @@ export class CDPRelayServer { } private _resetExtensionConnection() { - this._connectedTabInfo = undefined; + this._tabSessions.clear(); + this._tabIdToSessionId.clear(); this._extensionConnection = null; this._extensionConnectionPromise = new ManualPromise(); void this._extensionConnectionPromise.catch(logUnhandledError); @@ -244,17 +255,84 @@ export class CDPRelayServer { private _handleExtensionMessage(method: M, params: ExtensionEvents[M]['params']) { switch (method) { - case 'forwardCDPEvent': - const sessionId = params.sessionId || this._connectedTabInfo?.sessionId; + case 'chrome.debugger.onEvent': { + const [source, cdpMethod, cdpParams] = params as ExtensionEvents['chrome.debugger.onEvent']['params']; + if (source.tabId === undefined) + return; + const tabSessionId = this._tabIdToSessionId.get(source.tabId); + if (!tabSessionId) + return; + // Top-level CDP events for the tab use the tab's relay sessionId. + // Child CDP sessions (workers, oopifs) keep their own sessionId. + const sessionId = source.sessionId || tabSessionId; this._sendToPlaywright({ sessionId, - method: params.method, - params: params.params + method: cdpMethod, + params: cdpParams, }); break; + } + case 'chrome.debugger.onDetach': { + const [source] = params as ExtensionEvents['chrome.debugger.onDetach']['params']; + if (source.tabId !== undefined) + this._detachTab(source.tabId); + break; + } + case 'chrome.tabs.onCreated': { + const [tab] = params as ExtensionEvents['chrome.tabs.onCreated']['params']; + // A controlled tab opened a popup. Attach to it. + if (tab.id !== undefined) + void this._attachTab(tab.id).catch(logUnhandledError); + break; + } + case 'chrome.tabs.onRemoved': { + const [tabId] = params as ExtensionEvents['chrome.tabs.onRemoved']['params']; + this._detachTab(tabId); + break; + } } } + private async _attachTab(tabId: number): Promise { + if (this._tabIdToSessionId.has(tabId)) + return this._tabSessions.get(this._tabIdToSessionId.get(tabId)!)!; + if (!this._extensionConnection) + throw new Error('Extension not connected'); + await this._extensionConnection.send('chrome.debugger.attach', [{ tabId }, '1.3']); + const result = await this._extensionConnection.send('chrome.debugger.sendCommand', [ + { tabId }, + 'Target.getTargetInfo', + ]); + const targetInfo = result?.targetInfo; + const sessionId = `pw-tab-${this._nextSessionId++}`; + const tabSession: TabSession = { tabId, sessionId, targetInfo }; + this._tabSessions.set(sessionId, tabSession); + this._tabIdToSessionId.set(tabId, sessionId); + debugLogger(`Attached tab ${tabId} as session ${sessionId}`); + this._sendToPlaywright({ + method: 'Target.attachedToTarget', + params: { + sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, + }); + return tabSession; + } + + private _detachTab(tabId: number): void { + const sessionId = this._tabIdToSessionId.get(tabId); + if (!sessionId) + return; + this._tabIdToSessionId.delete(tabId); + this._tabSessions.delete(sessionId); + debugLogger(`Detached tab ${tabId} (session ${sessionId})`); + this._sendToPlaywright({ + method: 'Target.detachedFromTarget', + params: { sessionId }, + }); + } + private async _handlePlaywrightMessage(message: CDPCommand): Promise { debugLogger('← Playwright:', `${message.method} (id=${message.id})`); const { id, sessionId, method, params } = message; @@ -287,28 +365,26 @@ export class CDPRelayServer { // Forward child session handling. if (sessionId) break; - // Simulate auto-attach behavior with real target info - const { targetInfo } = await this._extensionConnection!.send('attachToTab', { }); - this._connectedTabInfo = { - targetInfo, - sessionId: `pw-tab-${this._nextSessionId++}`, - }; - debugLogger('Simulating auto-attach'); - this._sendToPlaywright({ - method: 'Target.attachedToTarget', - params: { - sessionId: this._connectedTabInfo.sessionId, - targetInfo: { - ...this._connectedTabInfo.targetInfo, - attached: true, - }, - waitingForDebugger: false - } - }); + // Ask the user to pick the initial tab via the connect UI, then attach. + if (!this._extensionConnection) + throw new Error('Extension not connected'); + const { tabId } = await this._extensionConnection.send('extension.selectTab', []); + await this._attachTab(tabId); return { }; } + case 'Target.createTarget': { + if (!this._extensionConnection) + throw new Error('Extension not connected'); + const tab = await this._extensionConnection.send('chrome.tabs.create', [{ url: params?.url }]); + if (tab?.id === undefined) + throw new Error('Failed to create tab'); + const tabSession = await this._attachTab(tab.id); + return { targetId: tabSession.targetInfo?.targetId }; + } case 'Target.getTargetInfo': { - return this._connectedTabInfo?.targetInfo; + if (sessionId) + return this._tabSessions.get(sessionId)?.targetInfo; + return this._tabSessions.values().next().value?.targetInfo; } } return await this._forwardToExtension(method, params, sessionId); @@ -317,10 +393,26 @@ export class CDPRelayServer { private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise { if (!this._extensionConnection) throw new Error('Extension not connected'); - // 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 }); + // Resolve the relay sessionId to a tabId. Child CDP sessions pass through unchanged. + let tabId: number | undefined; + let cdpSessionId: string | undefined = sessionId; + if (sessionId && this._tabSessions.has(sessionId)) { + tabId = this._tabSessions.get(sessionId)!.tabId; + cdpSessionId = undefined; + } + if (tabId === undefined) { + // No relay tab session — fall back to the only tab if there is one. + const first = this._tabSessions.values().next().value; + if (first) + tabId = first.tabId; + } + if (tabId === undefined) + throw new Error('No tab is connected'); + return await this._extensionConnection.send('chrome.debugger.sendCommand', [ + { tabId, sessionId: cdpSessionId }, + method, + params, + ]); } private _sendToPlaywright(message: CDPResponse): void { @@ -352,7 +444,7 @@ class ExtensionConnection { this._ws.on('error', this._onError.bind(this)); } - async send(method: M, params: ExtensionCommand[M]['params']): Promise { + async send(method: M, params: ExtensionCommand[M]['params']): Promise { if (this._ws.readyState !== ws.OPEN) throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`); const id = ++this._lastId; diff --git a/packages/playwright-core/src/tools/mcp/protocol.ts b/packages/playwright-core/src/tools/mcp/protocol.ts index 7b0cb261669ad..349c09a8c6dea 100644 --- a/packages/playwright-core/src/tools/mcp/protocol.ts +++ b/packages/playwright-core/src/tools/mcp/protocol.ts @@ -16,11 +16,88 @@ // Whenever the commands/events change, the version must be updated. The latest // extension version should be compatible with the old MCP clients. -export const VERSION = 1; +export const VERSION = 2; +// Structural mirrors of @types/chrome shapes used over the wire. The extension +// imports the real chrome.* types and they are structurally compatible. +export type Debuggee = { tabId?: number; extensionId?: string; targetId?: string }; +export type DebuggerSession = Debuggee & { sessionId?: string }; +export type TabCreateProperties = { + active?: boolean; + index?: number; + openerTabId?: number; + pinned?: boolean; + url?: string; + windowId?: number; +}; +export type Tab = { + id?: number; + index: number; + windowId: number; + openerTabId?: number; + url?: string; + title?: string; + active: boolean; + pinned: boolean; +}; +export type TabRemoveInfo = { windowId: number; isWindowClosing: boolean }; + +// Protocol v2: command params/results mirror chrome.* positional arguments, +// so the extension can spread them straight into chrome..(...). export type ExtensionCommand = { + // chrome.debugger.attach(target, requiredVersion) + 'chrome.debugger.attach': { + params: [target: Debuggee, requiredVersion: string]; + result: void; + }; + // chrome.debugger.detach(target) + 'chrome.debugger.detach': { + params: [target: Debuggee]; + result: void; + }; + // chrome.debugger.sendCommand(target, method, commandParams?) + 'chrome.debugger.sendCommand': { + params: [target: DebuggerSession, method: string, commandParams?: object]; + result: any; + }; + // chrome.tabs.create(createProperties) + 'chrome.tabs.create': { + params: [createProperties: TabCreateProperties]; + result: Tab; + }; + // Playwright-specific: ask the user to pick a tab via the connect UI. + 'extension.selectTab': { + params: []; + result: { tabId: number }; + }; +}; + +// Event params mirror chrome...addListener callback signatures. +export type ExtensionEvents = { + // chrome.debugger.onEvent: (source, method, params?) => void + 'chrome.debugger.onEvent': { + params: [source: DebuggerSession, method: string, eventParams?: object]; + }; + // chrome.debugger.onDetach: (source, reason) => void + 'chrome.debugger.onDetach': { + params: [source: Debuggee, reason: string]; + }; + // chrome.tabs.onCreated: (tab) => void + 'chrome.tabs.onCreated': { + params: [tab: Tab]; + }; + // chrome.tabs.onRemoved: (tabId, removeInfo) => void + 'chrome.tabs.onRemoved': { + params: [tabId: number, removeInfo: TabRemoveInfo]; + }; +}; + +// Protocol v1: legacy single-tab interface. Kept for backward compatibility +// with older MCP clients; will be removed in a future release. +export type ExtensionCommandV1 = { 'attachToTab': { params: {}; + result: { targetInfo: any }; }; 'forwardCDPCommand': { params: { @@ -28,10 +105,11 @@ export type ExtensionCommand = { sessionId?: string params?: any, }; + result: any; }; }; -export type ExtensionEvents = { +export type ExtensionEventsV1 = { 'forwardCDPEvent': { params: { method: string, From 0f715fe7d2fa7b5bccaa75038b753e46c2d1f769 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 7 Apr 2026 16:45:50 -0700 Subject: [PATCH 2/4] fix(mcp): route child CDP sessions to the owning tab The relay previously fell back to the first connected tab whenever a forwarded sessionId was not a relay-level tab session. This routed worker / oopif / iframe sessions belonging to a non-first tab to the wrong chrome.debugger session and produced "Session with given id not found" errors. Track child CDP sessions per tab by observing Target.attachedToTarget and Target.detachedFromTarget on chrome.debugger.onEvent and use the mapping in _forwardToExtension. Unrouted sessionIds now error explicitly instead of being silently misrouted. Entries are cleaned up on tab detach and on extension reconnect. --- .../playwright-core/src/tools/mcp/cdpRelay.ts | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index 56a9e83c1c7b1..04b289a5b1737 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -82,6 +82,10 @@ export class CDPRelayServer { // sessionId → TabSession (sessions known to the Playwright client). private _tabSessions = new Map(); private _tabIdToSessionId = new Map(); + // Maps a child CDP sessionId (e.g. for a worker, oopif) to the tabId that + // owns it. Populated by observing Target.attachedToTarget events from the + // extension and used by _forwardToExtension to route subsequent commands. + private _childSessionToTabId = new Map(); private _nextSessionId: number = 1; private _extensionConnectionPromise!: ManualPromise; @@ -225,6 +229,7 @@ export class CDPRelayServer { private _resetExtensionConnection() { this._tabSessions.clear(); this._tabIdToSessionId.clear(); + this._childSessionToTabId.clear(); this._extensionConnection = null; this._extensionConnectionPromise = new ManualPromise(); void this._extensionConnectionPromise.catch(logUnhandledError); @@ -262,6 +267,15 @@ export class CDPRelayServer { const tabSessionId = this._tabIdToSessionId.get(source.tabId); if (!tabSessionId) return; + // Track child CDP sessions so we can route subsequent commands for + // them to the correct tab. Target.attachedToTarget introduces a new + // sessionId belonging to the same tab; Target.detachedFromTarget + // releases it. + const childSessionId = (cdpParams as { sessionId?: string } | undefined)?.sessionId; + if (cdpMethod === 'Target.attachedToTarget' && childSessionId) + this._childSessionToTabId.set(childSessionId, source.tabId); + else if (cdpMethod === 'Target.detachedFromTarget' && childSessionId) + this._childSessionToTabId.delete(childSessionId); // Top-level CDP events for the tab use the tab's relay sessionId. // Child CDP sessions (workers, oopifs) keep their own sessionId. const sessionId = source.sessionId || tabSessionId; @@ -326,6 +340,11 @@ export class CDPRelayServer { return; this._tabIdToSessionId.delete(tabId); this._tabSessions.delete(sessionId); + // Drop any child CDP sessions that belonged to this tab. + for (const [childSessionId, childTabId] of this._childSessionToTabId) { + if (childTabId === tabId) + this._childSessionToTabId.delete(childSessionId); + } debugLogger(`Detached tab ${tabId} (session ${sessionId})`); this._sendToPlaywright({ method: 'Target.detachedFromTarget', @@ -382,32 +401,34 @@ export class CDPRelayServer { return { targetId: tabSession.targetInfo?.targetId }; } case 'Target.getTargetInfo': { - if (sessionId) - return this._tabSessions.get(sessionId)?.targetInfo; - return this._tabSessions.values().next().value?.targetInfo; + if (!sessionId) + return undefined; + return this._tabSessions.get(sessionId)?.targetInfo; } } + if (!sessionId) + throw new Error(`Unsupported command without sessionId: ${method}`); return await this._forwardToExtension(method, params, sessionId); } - private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise { + private async _forwardToExtension(method: string, params: any, sessionId: string): Promise { if (!this._extensionConnection) throw new Error('Extension not connected'); - // Resolve the relay sessionId to a tabId. Child CDP sessions pass through unchanged. + // Resolve the relay sessionId to a tabId. There are three cases: + // 1. sessionId is a relay-level tab session (pw-tab-N) → strip and route by tabId. + // 2. sessionId is a child CDP session (worker, oopif) → route to its owning tab, + // keep the sessionId so the extension forwards it to chrome.debugger. + // 3. No sessionId → there is no tab context to pick from, error out. let tabId: number | undefined; - let cdpSessionId: string | undefined = sessionId; - if (sessionId && this._tabSessions.has(sessionId)) { + let cdpSessionId: string | undefined; + if (this._tabSessions.has(sessionId)) { tabId = this._tabSessions.get(sessionId)!.tabId; - cdpSessionId = undefined; - } - if (tabId === undefined) { - // No relay tab session — fall back to the only tab if there is one. - const first = this._tabSessions.values().next().value; - if (first) - tabId = first.tabId; + } else if (this._childSessionToTabId.has(sessionId)) { + tabId = this._childSessionToTabId.get(sessionId); + cdpSessionId = sessionId; } if (tabId === undefined) - throw new Error('No tab is connected'); + throw new Error(`No tab found for sessionId: ${sessionId}`); return await this._extensionConnection.send('chrome.debugger.sendCommand', [ { tabId, sessionId: cdpSessionId }, method, From 3aa770b2bad84260696903ce30e4c84ca027cbca Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 7 Apr 2026 17:33:29 -0700 Subject: [PATCH 3/4] chore(mcp): simplify session bookkeeping with single tabId-keyed map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the side maps _tabIdToSessionId and _childSessionToTabId. The relay now uses a single Map, and TabSession owns its own Set of child CDP sessionIds. Lookups by sessionId or child sessionId are linear scans — only a handful of tabs are ever connected, so the extra index maps are not worth the bookkeeping. --- .../playwright-core/src/tools/mcp/cdpRelay.ts | 85 ++++++++++--------- 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index 04b289a5b1737..7d4fd19ba29af 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -67,6 +67,9 @@ type TabSession = { tabId: number; sessionId: string; targetInfo: any; + // Child CDP sessionIds (workers, oopifs, ...) belonging to this tab, + // tracked via Target.attachedToTarget / Target.detachedFromTarget events. + childSessions: Set; }; export class CDPRelayServer { @@ -79,13 +82,10 @@ export class CDPRelayServer { private _wss: WebSocketServer; private _playwrightConnection: WebSocket | null = null; private _extensionConnection: ExtensionConnection | null = null; - // sessionId → TabSession (sessions known to the Playwright client). - private _tabSessions = new Map(); - private _tabIdToSessionId = new Map(); - // Maps a child CDP sessionId (e.g. for a worker, oopif) to the tabId that - // owns it. Populated by observing Target.attachedToTarget events from the - // extension and used by _forwardToExtension to route subsequent commands. - private _childSessionToTabId = new Map(); + // The single source of truth for connected tabs, keyed by tabId. + // We linearly search this map for sessionId / child session lookups — only + // a handful of tabs are ever connected, so the extra index maps are unwanted. + private _tabSessions = new Map(); private _nextSessionId: number = 1; private _extensionConnectionPromise!: ManualPromise; @@ -228,13 +228,27 @@ export class CDPRelayServer { private _resetExtensionConnection() { this._tabSessions.clear(); - this._tabIdToSessionId.clear(); - this._childSessionToTabId.clear(); this._extensionConnection = null; this._extensionConnectionPromise = new ManualPromise(); void this._extensionConnectionPromise.catch(logUnhandledError); } + private _findTabSessionBySessionId(sessionId: string): TabSession | undefined { + for (const session of this._tabSessions.values()) { + if (session.sessionId === sessionId) + return session; + } + return undefined; + } + + private _findTabSessionByChildSessionId(childSessionId: string): TabSession | undefined { + for (const session of this._tabSessions.values()) { + if (session.childSessions.has(childSessionId)) + return session; + } + return undefined; + } + private _closePlaywrightConnection(reason: string) { if (this._playwrightConnection?.readyState === ws.OPEN) this._playwrightConnection.close(1000, reason); @@ -264,8 +278,8 @@ export class CDPRelayServer { const [source, cdpMethod, cdpParams] = params as ExtensionEvents['chrome.debugger.onEvent']['params']; if (source.tabId === undefined) return; - const tabSessionId = this._tabIdToSessionId.get(source.tabId); - if (!tabSessionId) + const tabSession = this._tabSessions.get(source.tabId); + if (!tabSession) return; // Track child CDP sessions so we can route subsequent commands for // them to the correct tab. Target.attachedToTarget introduces a new @@ -273,12 +287,12 @@ export class CDPRelayServer { // releases it. const childSessionId = (cdpParams as { sessionId?: string } | undefined)?.sessionId; if (cdpMethod === 'Target.attachedToTarget' && childSessionId) - this._childSessionToTabId.set(childSessionId, source.tabId); + tabSession.childSessions.add(childSessionId); else if (cdpMethod === 'Target.detachedFromTarget' && childSessionId) - this._childSessionToTabId.delete(childSessionId); + tabSession.childSessions.delete(childSessionId); // Top-level CDP events for the tab use the tab's relay sessionId. // Child CDP sessions (workers, oopifs) keep their own sessionId. - const sessionId = source.sessionId || tabSessionId; + const sessionId = source.sessionId || tabSession.sessionId; this._sendToPlaywright({ sessionId, method: cdpMethod, @@ -308,8 +322,9 @@ export class CDPRelayServer { } private async _attachTab(tabId: number): Promise { - if (this._tabIdToSessionId.has(tabId)) - return this._tabSessions.get(this._tabIdToSessionId.get(tabId)!)!; + const existing = this._tabSessions.get(tabId); + if (existing) + return existing; if (!this._extensionConnection) throw new Error('Extension not connected'); await this._extensionConnection.send('chrome.debugger.attach', [{ tabId }, '1.3']); @@ -319,9 +334,8 @@ export class CDPRelayServer { ]); const targetInfo = result?.targetInfo; const sessionId = `pw-tab-${this._nextSessionId++}`; - const tabSession: TabSession = { tabId, sessionId, targetInfo }; - this._tabSessions.set(sessionId, tabSession); - this._tabIdToSessionId.set(tabId, sessionId); + const tabSession: TabSession = { tabId, sessionId, targetInfo, childSessions: new Set() }; + this._tabSessions.set(tabId, tabSession); debugLogger(`Attached tab ${tabId} as session ${sessionId}`); this._sendToPlaywright({ method: 'Target.attachedToTarget', @@ -335,20 +349,14 @@ export class CDPRelayServer { } private _detachTab(tabId: number): void { - const sessionId = this._tabIdToSessionId.get(tabId); - if (!sessionId) + const tabSession = this._tabSessions.get(tabId); + if (!tabSession) return; - this._tabIdToSessionId.delete(tabId); - this._tabSessions.delete(sessionId); - // Drop any child CDP sessions that belonged to this tab. - for (const [childSessionId, childTabId] of this._childSessionToTabId) { - if (childTabId === tabId) - this._childSessionToTabId.delete(childSessionId); - } - debugLogger(`Detached tab ${tabId} (session ${sessionId})`); + this._tabSessions.delete(tabId); + debugLogger(`Detached tab ${tabId} (session ${tabSession.sessionId})`); this._sendToPlaywright({ method: 'Target.detachedFromTarget', - params: { sessionId }, + params: { sessionId: tabSession.sessionId }, }); } @@ -403,7 +411,7 @@ export class CDPRelayServer { case 'Target.getTargetInfo': { if (!sessionId) return undefined; - return this._tabSessions.get(sessionId)?.targetInfo; + return this._findTabSessionBySessionId(sessionId)?.targetInfo; } } if (!sessionId) @@ -414,23 +422,20 @@ export class CDPRelayServer { private async _forwardToExtension(method: string, params: any, sessionId: string): Promise { if (!this._extensionConnection) throw new Error('Extension not connected'); - // Resolve the relay sessionId to a tabId. There are three cases: + // Resolve the sessionId to a tab session. Two cases: // 1. sessionId is a relay-level tab session (pw-tab-N) → strip and route by tabId. // 2. sessionId is a child CDP session (worker, oopif) → route to its owning tab, // keep the sessionId so the extension forwards it to chrome.debugger. - // 3. No sessionId → there is no tab context to pick from, error out. - let tabId: number | undefined; + let tabSession = this._findTabSessionBySessionId(sessionId); let cdpSessionId: string | undefined; - if (this._tabSessions.has(sessionId)) { - tabId = this._tabSessions.get(sessionId)!.tabId; - } else if (this._childSessionToTabId.has(sessionId)) { - tabId = this._childSessionToTabId.get(sessionId); + if (!tabSession) { + tabSession = this._findTabSessionByChildSessionId(sessionId); cdpSessionId = sessionId; } - if (tabId === undefined) + if (!tabSession) throw new Error(`No tab found for sessionId: ${sessionId}`); return await this._extensionConnection.send('chrome.debugger.sendCommand', [ - { tabId, sessionId: cdpSessionId }, + { tabId: tabSession.tabId, sessionId: cdpSessionId }, method, params, ]); From b26f3e606f747ea6a864290f75d326911ae860c9 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 7 Apr 2026 17:35:03 -0700 Subject: [PATCH 4/4] remove comment --- packages/playwright-core/src/tools/mcp/cdpRelay.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index 7d4fd19ba29af..3b9593f44e361 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -82,12 +82,10 @@ export class CDPRelayServer { private _wss: WebSocketServer; private _playwrightConnection: WebSocket | null = null; private _extensionConnection: ExtensionConnection | null = null; - // The single source of truth for connected tabs, keyed by tabId. - // We linearly search this map for sessionId / child session lookups — only - // a handful of tabs are ever connected, so the extra index maps are unwanted. + private _extensionConnectionPromise!: ManualPromise; + private _tabSessions = new Map(); private _nextSessionId: number = 1; - private _extensionConnectionPromise!: ManualPromise; constructor(server: http.Server, browserChannel: string, userDataDir?: string, executablePath?: string) { this._wsHost = addressToString(server.address(), { protocol: 'ws' });