diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 881e69c8d80dd..b610264f3156a 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -14,7 +14,9 @@ * limitations under the License. */ -import { RelayConnection, debugLog } from './relayConnection'; +import { debugLog } from './relayConnection'; +import { PendingConnections } from './pendingConnection'; +import { ConnectedTabGroup, cleanupStalePlaywrightGroups, isNonDebuggableUrl } from './connectedTabGroup'; type PageMessage = { type: 'connectToMCPRelay'; @@ -35,55 +37,24 @@ type PageMessage = { type: 'rejectConnection'; }; -const PLAYWRIGHT_GROUP_TITLE = 'Playwright'; -const PLAYWRIGHT_GROUP_COLOR = 'green'; -const NON_DEBUGGABLE_SCHEMES = ['chrome:', 'edge:', 'devtools:']; -const CONNECTED_BADGE = { text: '✓', color: '#4CAF50', title: 'Connected to Playwright client' }; - -function isNonDebuggableUrl(url: string | undefined): boolean { - return !!url && NON_DEBUGGABLE_SCHEMES.some(s => url.startsWith(s)); -} - -class TabShareExtension { - private _activeConnection: RelayConnection | undefined; - // Source of truth for which tabs should be in the Playwright group. - private _connectedTabIds: Set = new Set(); - private _groupId: number | null = null; - // Serializes _reconcile calls to prevent concurrent group operations. - private _reconcileQueue: Promise = Promise.resolve(); - // True while _reconcile is actively mutating the group. onTabUpdated events - // fired during this window reflect our own changes, not user drags, so we - // skip handling them to avoid fighting the reconciler. - private _reconciling = false; - private _pendingTabSelection = new Map(); +class PlaywrightExtension { + private _activeGroup: ConnectedTabGroup | undefined; + private _pendingConnections = new PendingConnections(); + // Service worker restarts lose all connection state, so any existing + // Playwright groups are stale. Connections wait on this before reconciling. + private _cleanupPromise: Promise; constructor() { - chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this)); - chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this)); chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); chrome.action.onClicked.addListener(this._onActionClicked.bind(this)); - // Service worker restarts lose all connection state, so any existing - // Playwright groups are stale. Clean them up before any reconcile runs. - this._reconcileQueue = this._reconcileQueue.then(() => this._cleanupStaleGroups()); - } - - private async _cleanupStaleGroups(): Promise { - try { - const groups = await chrome.tabGroups.query({ title: PLAYWRIGHT_GROUP_TITLE }); - const tabsPerGroup = await Promise.all(groups.map(g => chrome.tabs.query({ groupId: g.id }))); - const tabIds = tabsPerGroup.flat().map(t => t.id).filter((id): id is number => id !== undefined); - if (tabIds.length) - await chrome.tabs.ungroup(tabIds); - } catch (error: any) { - debugLog('Error cleaning up stale groups:', error); - } + this._cleanupPromise = cleanupStalePlaywrightGroups(); } // Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031 private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) { switch (message.type) { case 'connectToMCPRelay': - this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl, message.protocolVersion).then( + this._pendingConnections.create(sender.tab!.id!, message.mcpRelayUrl, message.protocolVersion).then( () => sendResponse({ success: true }), (error: any) => sendResponse({ success: false, error: error.message })); return true; @@ -95,13 +66,13 @@ class TabShareExtension { case 'connectToTab': const tabId = message.tabId || sender.tab?.id!; const windowId = message.windowId || sender.tab?.windowId!; - this._connectTab(sender.tab!.id!, tabId, windowId, message.mcpRelayUrl!).then( + this._connectTab(sender.tab!.id!, tabId, windowId).then( () => sendResponse({ success: true }), (error: any) => sendResponse({ success: false, error: error.message })); return true; // Return true to indicate that the response will be sent asynchronously case 'getConnectionStatus': sendResponse({ - connectedTabIds: [...this._connectedTabIds] + connectedTabIds: this._activeGroup?.connectedTabIds() ?? [] }); return false; case 'disconnect': @@ -112,201 +83,45 @@ class TabShareExtension { sendResponse({ success: false, error: error.message }); } return true; - case 'rejectConnection': { - const selectorTabId = sender.tab?.id; - const pending = selectorTabId !== undefined ? this._pendingTabSelection.get(selectorTabId) : undefined; - if (pending) { - this._pendingTabSelection.delete(selectorTabId!); - pending.close('Rejected by user'); - } + case 'rejectConnection': + if (sender.tab?.id !== undefined) + this._pendingConnections.reject(sender.tab.id); sendResponse({ success: true }); return true; - } - } - } - - private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string, protocolVersion: number): Promise { - try { - const socket = new WebSocket(mcpRelayUrl); - await new Promise((resolve, reject) => { - socket.onopen = () => resolve(); - socket.onerror = () => reject(new Error('WebSocket error')); - setTimeout(() => reject(new Error('Connection timeout')), 5000); - }); - - const connection = new RelayConnection(socket, protocolVersion); - connection.onclose = () => { - const existed = this._pendingTabSelection.delete(selectorTabId); - if (existed) - chrome.tabs.sendMessage(selectorTabId, { type: 'pendingConnectionClosed' }).catch(() => {}); - }; - this._pendingTabSelection.set(selectorTabId, connection); - } catch (error: any) { - const message = `Failed to connect to MCP relay: ${error.message}`; - debugLog(message); - throw new Error(message); } } - private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise { + private async _connectTab(selectorTabId: number, tabId: number, windowId: number): Promise { try { + await this._cleanupPromise; this._disconnect('Another connection is requested'); - this._activeConnection = this._pendingTabSelection.get(selectorTabId); - if (!this._activeConnection) + const pending = this._pendingConnections.take(selectorTabId); + if (!pending) throw new Error('Pending client connection closed'); - this._pendingTabSelection.delete(selectorTabId); - this._activeConnection.setSelectedTab(tabId); - this._activeConnection.onclose = () => { - this._activeConnection = undefined; - const allTabIds = [...this._connectedTabIds]; - this._connectedTabIds.clear(); - allTabIds.map(id => this._updateBadge(id, { text: '' })); - void this._reconcile(); - }; - this._activeConnection.ontabattached = (newTabId: number) => { - this._connectedTabIds.add(newTabId); - void this._updateBadge(newTabId, CONNECTED_BADGE); - void this._reconcile(); - }; - this._activeConnection.ontabdetached = (removedTabId: number) => { - this._connectedTabIds.delete(removedTabId); - void this._updateBadge(removedTabId, { text: '' }); - void this._reconcile(); + const group = new ConnectedTabGroup(pending.connection, tabId); + group.onclose = () => { + if (this._activeGroup === group) + this._activeGroup = undefined; }; + this._activeGroup = group; await Promise.all([ chrome.tabs.update(tabId, { active: true }), chrome.windows.update(windowId, { focused: true }), ]); } catch (error: any) { - this._connectedTabIds.clear(); debugLog(`Failed to connect tab ${tabId}:`, error.message); throw error; } } - private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise { - try { - await Promise.all([ - chrome.action.setBadgeText({ tabId, text }), - chrome.action.setTitle({ tabId, title: title || '' }), - color ? chrome.action.setBadgeBackgroundColor({ tabId, color }) : Promise.resolve(), - ]); - } catch (error: any) { - // Ignore errors as the tab may be closed already. - } - } - - private async _onTabRemoved(tabId: number): Promise { - const pendingConnection = this._pendingTabSelection.get(tabId); - if (pendingConnection) { - this._pendingTabSelection.delete(tabId); - pendingConnection.close('Browser tab closed'); - } - // Closed connected tabs are handled by RelayConnection's own listeners. - } - - private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) { - // Chrome resets per-tab badge state on navigation, so re-apply it for - // connected tabs on any update. - if (this._connectedTabIds.has(tabId)) - void this._updateBadge(tabId, CONNECTED_BADGE); - - if (!this._activeConnection || changeInfo.groupId === undefined || this._reconciling) - return; - const inOurGroup = this._groupId !== null && changeInfo.groupId === this._groupId; - const connected = this._connectedTabIds.has(tabId); - if (inOurGroup === connected) - return; - if (inOurGroup && !isNonDebuggableUrl(tab.url)) - void this._activeConnection.attachTab(tabId); - else if (!inOurGroup) - void this._activeConnection.detachTab(tabId); - void this._reconcile(); - } - private async _getTabs(): Promise { const tabs = await chrome.tabs.query({}); return tabs.filter(tab => !isNonDebuggableUrl(tab.url)); } - // Brings Chrome's Playwright group in line with _connectedTabIds. Serialized - // via _reconcileQueue and retries on drag errors until the state matches. - private _reconcile(): Promise { - const result = this._reconcileQueue.then(() => this._reconcileImpl()); - this._reconcileQueue = result.catch(() => {}); - return result; - } - - private async _reconcileImpl(): Promise { - const delays = [0, 100, 200]; - let attempt = 0; - while (true) { - const delay = delays[attempt] ?? 400; - if (delay) - await new Promise(resolve => setTimeout(resolve, delay)); - try { - if (await this._reconcileOnce()) - return; - } catch (error: any) { - debugLog('Error reconciling group:', error); - return; - } - attempt++; - } - } - - private async _reconcileOnce(): Promise { - const desired = new Set(this._connectedTabIds); - - let actual = new Set(); - if (this._groupId !== null) { - try { - // tabGroups.get throws if Chrome dissolved the group (e.g. all tabs - // removed); run in parallel with the membership query. - const [, tabs] = await Promise.all([ - chrome.tabGroups.get(this._groupId), - chrome.tabs.query({ groupId: this._groupId }), - ]); - actual = new Set(tabs.map(t => t.id).filter((id): id is number => id !== undefined)); - } catch { - this._groupId = null; - } - } - - const toUngroup = [...actual].filter(id => !desired.has(id)); - const toAdd = [...desired].filter(id => !actual.has(id)); - if (!toUngroup.length && !toAdd.length) - return true; - - this._reconciling = true; - try { - if (toUngroup.length) - await chrome.tabs.ungroup(toUngroup); - if (toAdd.length) { - if (this._groupId === null) { - this._groupId = await chrome.tabs.group({ tabIds: toAdd }); - await chrome.tabGroups.update(this._groupId, { color: PLAYWRIGHT_GROUP_COLOR, title: PLAYWRIGHT_GROUP_TITLE }); - } else { - await chrome.tabs.group({ groupId: this._groupId, tabIds: toAdd }); - } - } - return true; - } catch (e: any) { - if (this._isDragError(e)) - return false; - throw e; - } finally { - this._reconciling = false; - } - } - - private _isDragError(e: any): boolean { - return e?.message?.includes('user may be dragging a tab'); - } - private async _onActionClicked(): Promise { await chrome.tabs.create({ url: chrome.runtime.getURL('status.html'), @@ -314,12 +129,12 @@ class TabShareExtension { }); } - // Closes the active connection if any. The onclose callback installed in - // _connectTab handles all state cleanup (connectedTabIds, badges, reconcile). + // Closes the active group's connection if any. ConnectedTabGroup's onclose + // handles state cleanup (connectedTabIds, badges, reconcile). private _disconnect(reason: string) { - this._activeConnection?.close(reason); - this._activeConnection = undefined; + this._activeGroup?.close(reason); + this._activeGroup = undefined; } } -new TabShareExtension(); +new PlaywrightExtension(); diff --git a/packages/extension/src/connectedTabGroup.ts b/packages/extension/src/connectedTabGroup.ts new file mode 100644 index 0000000000000..7d7a2ba8649fc --- /dev/null +++ b/packages/extension/src/connectedTabGroup.ts @@ -0,0 +1,207 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RelayConnection, debugLog } from './relayConnection'; + +const PLAYWRIGHT_GROUP_TITLE = 'Playwright'; +const PLAYWRIGHT_GROUP_COLOR = 'green'; +const NON_DEBUGGABLE_SCHEMES = ['chrome:', 'edge:', 'devtools:']; +const CONNECTED_BADGE = { text: '✓', color: '#4CAF50', title: 'Connected to Playwright client' }; + +export function isNonDebuggableUrl(url: string | undefined): boolean { + return !!url && NON_DEBUGGABLE_SCHEMES.some(s => url.startsWith(s)); +} + +// Ungroups any Playwright-titled groups left behind by a prior service worker. +export async function cleanupStalePlaywrightGroups(): Promise { + try { + const groups = await chrome.tabGroups.query({ title: PLAYWRIGHT_GROUP_TITLE }); + const tabsPerGroup = await Promise.all(groups.map(g => chrome.tabs.query({ groupId: g.id }))); + const tabIds = tabsPerGroup.flat().map(t => t.id).filter((id): id is number => id !== undefined); + if (tabIds.length) + await chrome.tabs.ungroup(tabIds); + } catch (error: any) { + debugLog('Error cleaning up stale groups:', error); + } +} + +// The Playwright tab group for an active RelayConnection: `_connectedTabIds` +// is the source of truth for which tabs the client drives, and `_reconcile` +// pushes that set into Chrome's tab group model. +export class ConnectedTabGroup { + private _connection: RelayConnection; + private _connectedTabIds: Set = new Set(); + private _groupId: number | null = null; + // Serializes _reconcile calls to prevent concurrent group operations. + private _reconcileQueue: Promise = Promise.resolve(); + // True while _reconcile is actively mutating the group. onTabUpdated events + // fired during this window reflect our own changes, not user drags, so we + // skip handling them to avoid fighting the reconciler. + private _reconciling = false; + private _onTabUpdatedListener: (tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => void; + + onclose?: () => void; + + constructor(connection: RelayConnection, selectedTabId: number) { + this._connection = connection; + this._connection.setSelectedTab(selectedTabId); + this._connection.onclose = () => this._onConnectionClose(); + this._connection.ontabattached = (tabId: number) => this._onTabAttached(tabId); + this._connection.ontabdetached = (tabId: number) => this._onTabDetached(tabId); + this._onTabUpdatedListener = this._onTabUpdated.bind(this); + chrome.tabs.onUpdated.addListener(this._onTabUpdatedListener); + } + + connectedTabIds(): number[] { + return [...this._connectedTabIds]; + } + + close(reason: string): void { + this._connection.close(reason); + } + + private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): void { + // Chrome resets per-tab badge state on navigation, so re-apply it. + if (this._connectedTabIds.has(tabId)) + void this._updateBadge(tabId, CONNECTED_BADGE); + if (changeInfo.groupId !== undefined) + this._onTabGroupChanged(tabId, changeInfo.groupId, tab.url); + } + + // Translates a user drag in/out of the Playwright group into attach/detach + // on the relay. + private _onTabGroupChanged(tabId: number, newGroupId: number, url: string | undefined): void { + if (this._reconciling) + return; + const inOurGroup = this._groupId !== null && newGroupId === this._groupId; + const connected = this._connectedTabIds.has(tabId); + if (inOurGroup === connected) + return; + if (inOurGroup && !isNonDebuggableUrl(url)) + void this._connection.attachTab(tabId); + else if (!inOurGroup) + void this._connection.detachTab(tabId); + void this._reconcile(); + } + + private _onTabAttached(tabId: number): void { + this._connectedTabIds.add(tabId); + void this._updateBadge(tabId, CONNECTED_BADGE); + void this._reconcile(); + } + + private _onTabDetached(tabId: number): void { + this._connectedTabIds.delete(tabId); + void this._updateBadge(tabId, { text: '' }); + void this._reconcile(); + } + + private _onConnectionClose(): void { + chrome.tabs.onUpdated.removeListener(this._onTabUpdatedListener); + const allTabIds = [...this._connectedTabIds]; + this._connectedTabIds.clear(); + allTabIds.forEach(id => void this._updateBadge(id, { text: '' })); + void this._reconcile(); + this.onclose?.(); + } + + private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise { + try { + await Promise.all([ + chrome.action.setBadgeText({ tabId, text }), + chrome.action.setTitle({ tabId, title: title || '' }), + color ? chrome.action.setBadgeBackgroundColor({ tabId, color }) : Promise.resolve(), + ]); + } catch (error: any) { + // Ignore errors as the tab may be closed already. + } + } + + // Brings Chrome's Playwright group in line with _connectedTabIds. Serialized + // via _reconcileQueue and retries on drag errors until the state matches. + private _reconcile(): Promise { + const result = this._reconcileQueue.then(() => this._reconcileImpl()); + this._reconcileQueue = result.catch(() => {}); + return result; + } + + private async _reconcileImpl(): Promise { + const delays = [0, 100, 200]; + let attempt = 0; + while (true) { + const delay = delays[attempt] ?? 400; + if (delay) + await new Promise(resolve => setTimeout(resolve, delay)); + try { + if (await this._reconcileOnce()) + return; + } catch (error: any) { + debugLog('Error reconciling group:', error); + return; + } + attempt++; + } + } + + private async _reconcileOnce(): Promise { + const desired = new Set(this._connectedTabIds); + + let actual = new Set(); + if (this._groupId !== null) { + try { + // tabGroups.get throws if Chrome dissolved the group (e.g. all tabs + // removed); run in parallel with the membership query. + const [, tabs] = await Promise.all([ + chrome.tabGroups.get(this._groupId), + chrome.tabs.query({ groupId: this._groupId }), + ]); + actual = new Set(tabs.map(t => t.id).filter((id): id is number => id !== undefined)); + } catch { + this._groupId = null; + } + } + + const toUngroup = [...actual].filter(id => !desired.has(id)); + const toAdd = [...desired].filter(id => !actual.has(id)); + if (!toUngroup.length && !toAdd.length) + return true; + + this._reconciling = true; + try { + if (toUngroup.length) + await chrome.tabs.ungroup(toUngroup); + if (toAdd.length) { + if (this._groupId === null) { + this._groupId = await chrome.tabs.group({ tabIds: toAdd }); + await chrome.tabGroups.update(this._groupId, { color: PLAYWRIGHT_GROUP_COLOR, title: PLAYWRIGHT_GROUP_TITLE }); + } else { + await chrome.tabs.group({ groupId: this._groupId, tabIds: toAdd }); + } + } + return true; + } catch (e: any) { + if (this._isDragError(e)) + return false; + throw e; + } finally { + this._reconciling = false; + } + } + + private _isDragError(e: any): boolean { + return e?.message?.includes('user may be dragging a tab'); + } +} diff --git a/packages/extension/src/pendingConnection.ts b/packages/extension/src/pendingConnection.ts new file mode 100644 index 0000000000000..000e350f037ec --- /dev/null +++ b/packages/extension/src/pendingConnection.ts @@ -0,0 +1,100 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RelayConnection, debugLog } from './relayConnection'; + +// A RelayConnection opened by the connect page that has not yet been promoted +// to an active ConnectedTabGroup (the user hasn't picked a tab). Owns the +// RelayConnection until `connection` is handed off. +export class PendingConnection { + readonly connection: RelayConnection; + readonly selectorTabId: number; + onclose?: () => void; + + private constructor(connection: RelayConnection, selectorTabId: number) { + this.connection = connection; + this.selectorTabId = selectorTabId; + this.connection.onclose = () => this.onclose?.(); + } + + static async connect(selectorTabId: number, mcpRelayUrl: string, protocolVersion: number): Promise { + try { + const socket = new WebSocket(mcpRelayUrl); + await new Promise((resolve, reject) => { + socket.onopen = () => resolve(); + socket.onerror = () => reject(new Error('WebSocket error')); + setTimeout(() => reject(new Error('Connection timeout')), 5000); + }); + const connection = new RelayConnection(socket, protocolVersion); + return new PendingConnection(connection, selectorTabId); + } catch (error: any) { + const message = `Failed to connect to MCP relay: ${error.message}`; + debugLog(message); + throw new Error(message); + } + } + + close(reason: string): void { + this.connection.close(reason); + } +} + +// Collection of PendingConnections keyed by their selector (connect page) tab. +// Owns the tab-removal listener that closes pendings whose selector tab went +// away, and notifies the connect page when the relay drops its socket. +export class PendingConnections { + private _map = new Map(); + + constructor() { + chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this)); + } + + async create(selectorTabId: number, mcpRelayUrl: string, protocolVersion: number): Promise { + const pending = await PendingConnection.connect(selectorTabId, mcpRelayUrl, protocolVersion); + pending.onclose = () => { + const existed = this._map.delete(selectorTabId); + if (existed) + chrome.tabs.sendMessage(selectorTabId, { type: 'pendingConnectionClosed' }).catch(() => {}); + }; + this._map.set(selectorTabId, pending); + } + + reject(selectorTabId: number): void { + const pending = this._map.get(selectorTabId); + if (!pending) + return; + this._map.delete(selectorTabId); + pending.close('Rejected by user'); + } + + // Hands off ownership of the pending connection. The caller is expected to + // immediately transfer its RelayConnection to an active ConnectedTabGroup, which + // replaces `onclose` so the pending's handler no longer fires. + take(selectorTabId: number): PendingConnection | undefined { + const pending = this._map.get(selectorTabId); + if (pending) + this._map.delete(selectorTabId); + return pending; + } + + private _onTabRemoved(tabId: number): void { + const pending = this._map.get(tabId); + if (!pending) + return; + this._map.delete(tabId); + pending.close('Browser tab closed'); + } +} diff --git a/packages/extension/src/protocolHandlers.ts b/packages/extension/src/protocolHandlers.ts new file mode 100644 index 0000000000000..a533b0216375b --- /dev/null +++ b/packages/extension/src/protocolHandlers.ts @@ -0,0 +1,195 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { debugLog } from './relayConnection'; + +export type ProtocolCommand = { + id: number; + method: string; + params?: any; +}; + +// The narrow surface of RelayConnection that protocol handlers use. +export interface RelayContext { + readonly selectedTab: Promise; + readonly attachedTabs: ReadonlySet; + sendMessage(message: any): void; + // Records that a tab's debugger is now attached. Fires ontabattached on the + // owning RelayConnection. + notifyTabAttached(tabId: number): void; + // Records that a tab's debugger is now detached. Fires ontabdetached on the + // owning RelayConnection. + notifyTabDetached(tabId: number): void; +} + +export interface ProtocolHandler { + handleCommand(message: ProtocolCommand): Promise; + // Forwards an already-filtered chrome.* event (concerning a currently-attached + // tab) to the relay. Shape is protocol-specific. + forwardChromeEvent(fullMethod: string, args: any[]): void; + // The UI added a tab to the Playwright group. Handler tells the relay the + // tab is now available; the relay attaches via the usual command path. + onUserAttachRequest(tabId: number): Promise; + // The UI removed a tab. RelayConnection has already detached the debugger + // and called notifyTabDetached; the handler only sends the wire-level + // detach notification (if the protocol has one). + onUserDetachRequest(tabId: number): void; +} + +// ─── Protocol v1 (legacy single-tab) ─────────────────────────────────────── + +export class ProtocolV1Handler implements ProtocolHandler { + private _context: RelayContext; + + constructor(context: RelayContext) { + this._context = context; + } + + async handleCommand(message: ProtocolCommand): Promise { + if (message.method === 'extension.selectTab') { + const tabId = await this._context.selectedTab; + return { tabId }; + } + if (message.method === 'attachToTab') { + const tabId = await this._context.selectedTab; + const debuggee: chrome.debugger.Debuggee = { tabId }; + await chrome.debugger.attach(debuggee, '1.3'); + this._context.notifyTabAttached(tabId); + const result: any = await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'); + return { targetInfo: result?.targetInfo }; + } + if (message.method === 'forwardCDPCommand') { + const { sessionId, method, params } = message.params; + if (method === 'Target.createTarget') + throw new Error('Tab creation is not supported yet. Update Playwright MCP or CLI to the latest version.'); + const tabId = [...this._context.attachedTabs][0]; + if (tabId === undefined) + throw new Error('No tab is connected'); + const debuggerSession: chrome.debugger.DebuggerSession = { tabId, sessionId }; + return await chrome.debugger.sendCommand(debuggerSession, method, params); + } + throw new Error(`Unknown method: ${message.method}`); + } + + forwardChromeEvent(fullMethod: string, args: any[]): void { + // v1 only forwards CDP events from the single attached tab; all other + // chrome events have no v1 equivalent. + if (fullMethod !== 'chrome.debugger.onEvent') + return; + const [source, method, params] = args as [chrome.debugger.DebuggerSession, string, any]; + this._context.sendMessage({ + method: 'forwardCDPEvent', + params: { sessionId: source.sessionId, method, params }, + }); + } + + async onUserAttachRequest(_tabId: number): Promise { + // v1 is single-tab by design; dragging extra tabs into the group is a no-op. + } + + onUserDetachRequest(_tabId: number): void { + // v1 has no wire-level detach notification; when the last tab detaches the + // socket closes and the relay notices. + } +} + +// ─── Protocol v2 (reflective chrome.*) ───────────────────────────────────── + +// Allow-listed chrome.* commands the relay may invoke. The handler resolves +// the method reflectively and spreads positional params. +const ALLOWED_CHROME_COMMANDS = new Set([ + 'chrome.debugger.attach', + 'chrome.debugger.detach', + 'chrome.debugger.sendCommand', + 'chrome.tabs.create', + 'chrome.tabs.remove', +]); + +export class ProtocolV2Handler implements ProtocolHandler { + private _context: RelayContext; + + constructor(context: RelayContext) { + this._context = context; + } + + async handleCommand(message: ProtocolCommand): Promise { + if (message.method === 'extension.selectTab') { + const tabId = await this._context.selectedTab; + return { tabId }; + } + if (ALLOWED_CHROME_COMMANDS.has(message.method)) { + const args = (message.params ?? []) as any[]; + const result = await invokeChromeMethod(message.method, args); + // Attach bookkeeping; detach flows through the chrome.debugger.onDetach event. + if (message.method === 'chrome.debugger.attach') { + const target = args[0] as chrome.debugger.Debuggee | undefined; + if (target?.tabId !== undefined) + this._context.notifyTabAttached(target.tabId); + } + return result ?? {}; + } + throw new Error(`Unknown method: ${message.method}`); + } + + forwardChromeEvent(fullMethod: string, args: any[]): void { + this._context.sendMessage({ method: fullMethod, params: args }); + } + + async onUserAttachRequest(tabId: number): Promise { + // Simulate a "new tab opened" event; the relay responds by calling + // chrome.debugger.attach, which flows through handleCommand. + try { + const tab = await chrome.tabs.get(tabId); + this._context.sendMessage({ method: 'chrome.tabs.onCreated', params: [tab] }); + } catch (error: any) { + debugLog('Error requesting attach for tab:', error); + } + } + + onUserDetachRequest(tabId: number): void { + // chrome.debugger.detach does not fire onDetach for the caller, so we + // synthesize one so the relay notices the tab is gone. + this._context.sendMessage({ + method: 'chrome.debugger.onDetach', + params: [{ tabId }, 'target_closed'], + }); + } +} + +// ─── Reflective chrome.* invocation ──────────────────────────────────────── + +// Resolves chrome... Exported so RelayConnection can install +// listeners on the same set of chrome events without duplicating the traversal. +export function resolveChromeMember(fullMethod: string): { obj: any; name: string } { + const parts = fullMethod.split('.'); + if (parts[0] !== 'chrome' || parts.length < 3) + throw new Error(`Invalid chrome method: ${fullMethod}`); + let obj: any = chrome; + for (let i = 1; i < parts.length - 1; i++) { + obj = obj?.[parts[i]]; + if (obj === undefined) + throw new Error(`Unknown chrome path: ${parts.slice(0, i + 1).join('.')}, calling ${fullMethod}`); + } + return { obj, name: parts[parts.length - 1] }; +} + +async function invokeChromeMethod(fullMethod: string, args: any[]): Promise { + const { obj, name } = resolveChromeMember(fullMethod); + const fn = obj[name] as (...a: any[]) => any; + if (typeof fn !== 'function') + throw new Error(`Not a function: ${fullMethod}`); + return await fn.apply(obj, args); +} diff --git a/packages/extension/src/relayConnection.ts b/packages/extension/src/relayConnection.ts index 6f14e543a7b6c..4988731733e91 100644 --- a/packages/extension/src/relayConnection.ts +++ b/packages/extension/src/relayConnection.ts @@ -22,11 +22,10 @@ export function debugLog(...args: unknown[]): void { } } -type ProtocolCommand = { - id: number; - method: string; - params?: any; -}; +import { + ProtocolCommand, ProtocolHandler, ProtocolV1Handler, ProtocolV2Handler, + RelayContext, resolveChromeMember, +} from './protocolHandlers'; type ProtocolResponse = { id?: number; @@ -36,33 +35,17 @@ type ProtocolResponse = { error?: string; }; -// Allow-listed chrome.* commands the relay may invoke. The handler resolves -// the method reflectively and spreads positional params. -const ALLOWED_CHROME_COMMANDS = new Set([ - 'chrome.debugger.attach', - 'chrome.debugger.detach', - 'chrome.debugger.sendCommand', - 'chrome.tabs.create', - 'chrome.tabs.remove', -]); - // chrome.* events the extension forwards to the relay (positional params). -type ChromeEvent = { - api: 'chrome.debugger' | 'chrome.tabs'; - event: 'onEvent' | 'onDetach' | 'onCreated' | 'onRemoved'; - fullMethod: string; -}; - -const CHROME_EVENTS: ChromeEvent[] = [ - { api: 'chrome.debugger', event: 'onEvent', fullMethod: 'chrome.debugger.onEvent' }, - { api: 'chrome.debugger', event: 'onDetach', fullMethod: 'chrome.debugger.onDetach' }, - { api: 'chrome.tabs', event: 'onCreated', fullMethod: 'chrome.tabs.onCreated' }, - { api: 'chrome.tabs', event: 'onRemoved', fullMethod: 'chrome.tabs.onRemoved' }, +const CHROME_EVENT_METHODS = [ + 'chrome.debugger.onEvent', + 'chrome.debugger.onDetach', + 'chrome.tabs.onCreated', + 'chrome.tabs.onRemoved', ]; export class RelayConnection { private _ws: WebSocket; - private _protocolVersion: number; + private _handler: ProtocolHandler; // Tabs whose debugger we have explicitly attached for this connection. private _attachedTabs = new Set(); // Once we've attached at least one tab, detaching the last one closes the connection. @@ -78,8 +61,17 @@ export class RelayConnection { constructor(ws: WebSocket, protocolVersion: number) { this._ws = ws; - this._protocolVersion = protocolVersion; this._selectedTabPromise = new Promise(resolve => this._selectedTabResolve = resolve); + const context: RelayContext = { + selectedTab: this._selectedTabPromise, + attachedTabs: this._attachedTabs, + sendMessage: msg => this._sendMessage(msg), + notifyTabAttached: tabId => this._notifyTabAttached(tabId), + notifyTabDetached: tabId => this._notifyTabDetached(tabId), + }; + this._handler = protocolVersion === 1 + ? new ProtocolV1Handler(context) + : new ProtocolV2Handler(context); this._installEventForwarders(); this._ws.onmessage = this._onMessage.bind(this); this._ws.onclose = () => this._onClose(); @@ -97,49 +89,44 @@ export class RelayConnection { this._onClose(); } - // Simulates a "new tab opened" event for a tab the user added to the group. - // The relay reacts by issuing chrome.debugger.attach, which flows through - // the normal command path and fires ontabattached. + // Called when the UI adds a tab to the Playwright group. The handler asks + // the relay to attach; the normal command path fires ontabattached. async attachTab(tabId: number): Promise { - if (this._closed || this._protocolVersion !== 2) + if (this._closed || this._attachedTabs.has(tabId)) return; - if (this._attachedTabs.has(tabId)) - return; - try { - const tab = await chrome.tabs.get(tabId); - this._sendMessage({ method: 'chrome.tabs.onCreated', params: [tab] }); - } catch (error: any) { - debugLog('Error requesting attach for tab:', error); - } + await this._handler.onUserAttachRequest(tabId); } - // Simulates a "tab closed" event for a tab the user removed from the group. - // chrome.debugger.detach does not fire onDetach for the caller, so we do the - // bookkeeping and notify the relay ourselves. + // Called when the UI removes a tab from the Playwright group. We detach the + // debugger and update bookkeeping; the handler emits the wire-level detach + // notification for protocols that have one. async detachTab(tabId: number): Promise { - if (this._closed) - return; - if (!this._attachedTabs.has(tabId)) + if (this._closed || !this._attachedTabs.has(tabId)) return; try { await chrome.debugger.detach({ tabId }); } catch (error: any) { debugLog('Error detaching tab:', error); } + this._notifyTabDetached(tabId); + this._handler.onUserDetachRequest(tabId); + this._checkLastTabDetached(); + } + + private _notifyTabAttached(tabId: number): void { + this._attachedTabs.add(tabId); + this._hasEverAttached = true; + this.ontabattached?.(tabId); + } + + private _notifyTabDetached(tabId: number): void { this._attachedTabs.delete(tabId); this.ontabdetached?.(tabId); - if (this._protocolVersion === 2) { - this._sendMessage({ - method: 'chrome.debugger.onDetach', - params: [{ tabId }, 'target_closed'], - }); - } - this._checkLastTabDetached(); } private _installEventForwarders(): void { - for (const { fullMethod } of CHROME_EVENTS) { - const target = this._resolveChromeMember(fullMethod); + for (const fullMethod of CHROME_EVENT_METHODS) { + const target = resolveChromeMember(fullMethod); const listener = (...args: any[]) => this._onChromeEvent(fullMethod, args); target.obj[target.name].addListener(listener); this._eventListeners.push({ @@ -166,35 +153,16 @@ export class RelayConnection { this.close('All controlled tabs detached'); } - // Single dispatcher for every forwarded chrome.* event. + // Filters chrome.* events to attached tabs, delegates wire formatting to the + // handler, then runs shared detach bookkeeping. private _onChromeEvent(fullMethod: string, args: any[]): void { - // Filter events to those concerning tabs we've explicitly attached. const tabId = this._tabIdForEventArgs(fullMethod, args); if (tabId === undefined || !this._attachedTabs.has(tabId)) return; - - // v1 only forwards CDP events from the single attached tab. - if (this._protocolVersion === 1) { - if (fullMethod === 'chrome.debugger.onEvent') { - const [source, method, params] = args as [chrome.debugger.DebuggerSession, string, any]; - this._sendMessage({ - method: 'forwardCDPEvent', - params: { - sessionId: source.sessionId, - method, - params, - }, - }); - } - // Other events have no v1 equivalent — drop them. Detach bookkeeping happens below. - } else { - this._sendMessage({ method: fullMethod, params: args }); - } - - // Detach bookkeeping (single source of truth: chrome.debugger.onDetach). + this._handler.forwardChromeEvent(fullMethod, args); + // chrome.debugger.onDetach is the single source of truth for detach bookkeeping. if (fullMethod === 'chrome.debugger.onDetach') { - this._attachedTabs.delete(tabId); - this.ontabdetached?.(tabId); + this._notifyTabDetached(tabId); this._checkLastTabDetached(); } } @@ -235,7 +203,7 @@ export class RelayConnection { id: message.id, }; try { - response.result = await this._handleCommand(message); + response.result = await this._handler.handleCommand(message); } catch (error: any) { debugLog(`Error handling command ${JSON.stringify(message)}:`, error); response.error = error.message; @@ -243,81 +211,6 @@ export class RelayConnection { this._sendMessage(response); } - private async _handleCommand(message: ProtocolCommand): Promise { - // Playwright-specific tab picker. - if (message.method === 'extension.selectTab') { - const tabId = await this._selectedTabPromise; - return { tabId }; - } - - // Reflective chrome.* dispatch: spread positional params into the API. - if (ALLOWED_CHROME_COMMANDS.has(message.method)) { - const args = (message.params ?? []) as any[]; - const result = await this._invokeChromeMethod(message.method, args); - this._postChromeCommand(message.method, args); - return result ?? {}; - } - - // ─── Protocol v1 (legacy single-tab) ───────────────────────────────────── - if (message.method === 'attachToTab') { - const tabId = await this._selectedTabPromise; - const debuggee: chrome.debugger.Debuggee = { tabId }; - await chrome.debugger.attach(debuggee, '1.3'); - this._attachedTabs.add(tabId); - this._hasEverAttached = true; - this.ontabattached?.(tabId); - const result: any = await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'); - return { targetInfo: result?.targetInfo }; - } - if (message.method === 'forwardCDPCommand') { - const { sessionId, method, params } = message.params; - if (method === 'Target.createTarget') - throw new Error('Tab creation is not supported yet. Update Playwright MCP or CLI to the latest version.'); - const tabId = [...this._attachedTabs][0]; - if (tabId === undefined) - throw new Error('No tab is connected'); - const debuggerSession: chrome.debugger.DebuggerSession = { tabId, sessionId }; - return await chrome.debugger.sendCommand(debuggerSession, method, params); - } - - throw new Error(`Unknown method: ${message.method}`); - } - - // Reflectively resolves chrome.. and invokes it with positional args. - private async _invokeChromeMethod(fullMethod: string, args: any[]): Promise { - const { obj, name } = this._resolveChromeMember(fullMethod); - const fn = obj[name] as (...a: any[]) => any; - if (typeof fn !== 'function') - throw new Error(`Not a function: ${fullMethod}`); - return await fn.apply(obj, args); - } - - // Bookkeeping that must run after a successful chrome.* command. - private _postChromeCommand(fullMethod: string, args: any[]): void { - if (fullMethod === 'chrome.debugger.attach') { - const target = args[0] as chrome.debugger.Debuggee; - if (target.tabId !== undefined) { - this._attachedTabs.add(target.tabId); - this._hasEverAttached = true; - this.ontabattached?.(target.tabId); - } - } - // Detach is handled via the chrome.debugger.onDetach event listener. - } - - private _resolveChromeMember(fullMethod: string): { obj: any; name: string } { - const parts = fullMethod.split('.'); - if (parts[0] !== 'chrome' || parts.length < 3) - throw new Error(`Invalid chrome method: ${fullMethod}`); - let obj: any = chrome; - for (let i = 1; i < parts.length - 1; i++) { - obj = obj?.[parts[i]]; - if (obj === undefined) - throw new Error(`Unknown chrome path: ${parts.slice(0, i + 1).join('.')}, calling ${fullMethod}`); - } - return { obj, name: parts[parts.length - 1] }; - } - private _sendError(code: number, message: string): void { this._sendMessage({ error: { diff --git a/packages/playwright-core/src/tools/cli-client/DEPS.list b/packages/playwright-core/src/tools/cli-client/DEPS.list index e67b27a71f9c3..403e76efa831e 100644 --- a/packages/playwright-core/src/tools/cli-client/DEPS.list +++ b/packages/playwright-core/src/tools/cli-client/DEPS.list @@ -11,10 +11,12 @@ [output.ts] "strict" +../utils/extension.ts ./channelSessions.ts [channelSessions.ts] "strict" +../utils/extension.ts [session.ts] "strict" diff --git a/packages/playwright-core/src/tools/cli-client/channelSessions.ts b/packages/playwright-core/src/tools/cli-client/channelSessions.ts index 850df85cd66af..554984c667dcd 100644 --- a/packages/playwright-core/src/tools/cli-client/channelSessions.ts +++ b/packages/playwright-core/src/tools/cli-client/channelSessions.ts @@ -19,6 +19,8 @@ import net from 'net'; import os from 'os'; import path from 'path'; +import { playwrightExtensionId } from '../utils/extension'; + export type ChannelSession = { channel: string; userDataDir: string; @@ -26,12 +28,6 @@ export type ChannelSession = { extensionInstalled: boolean; }; -// Keep in sync with the id declared via "key" in packages/extension/manifest.json -// and the hardcoded url in packages/playwright-core/src/tools/mcp/cdpRelay.ts. -const playwrightExtensionId = 'mmlmfjhmonkocbjadbfplnigmagldckm'; - -export const playwrightExtensionInstallUrl = `https://chromewebstore.google.com/detail/playwright-mcp-bridge/${playwrightExtensionId}`; - export async function listChannelSessions(): Promise { if (process.env.PWTEST_CLI_CHANNEL_SCAN_DISABLED_FOR_TEST) return []; diff --git a/packages/playwright-core/src/tools/cli-client/output.ts b/packages/playwright-core/src/tools/cli-client/output.ts index b4d9dd8b1501e..b188e4965a23e 100644 --- a/packages/playwright-core/src/tools/cli-client/output.ts +++ b/packages/playwright-core/src/tools/cli-client/output.ts @@ -19,7 +19,7 @@ import path from 'path'; -import { playwrightExtensionInstallUrl } from './channelSessions'; +import { playwrightExtensionInstallUrl } from '../utils/extension'; import type { ChannelSession } from './channelSessions'; import type { BrowserStatus } from '../../serverRegistry'; diff --git a/packages/playwright-core/src/tools/mcp/DEPS.list b/packages/playwright-core/src/tools/mcp/DEPS.list index 187312272b193..eea60a2eb4441 100644 --- a/packages/playwright-core/src/tools/mcp/DEPS.list +++ b/packages/playwright-core/src/tools/mcp/DEPS.list @@ -2,6 +2,7 @@ ../../.. ../../ ../utils/mcp/ +../utils/extension.ts ../backend/ @utils/** @isomorphic/** diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index 20275e3a3128f..602d6ac8a0c35 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -35,6 +35,7 @@ import ws, { WebSocketServer as wsServer } from 'ws'; import { ManualPromise } from '@isomorphic/manualPromise'; import { registry } from '../../server/registry/index'; +import { playwrightExtensionId } from '../utils/extension'; import { addressToString } from '../utils/mcp/http'; import { logUnhandledError } from './log'; import { ExtensionProtocolV1 } from './cdpRelayV1'; @@ -125,8 +126,7 @@ export class CDPRelayServer { private _connectBrowser(clientName: string) { const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`; - // Need to specify "key" in the manifest.json to make the id stable when loading from file. - const url = new URL('chrome-extension://mmlmfjhmonkocbjadbfplnigmagldckm/connect.html'); + const url = new URL(`chrome-extension://${playwrightExtensionId}/connect.html`); url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint); const client = { name: clientName, diff --git a/packages/playwright-core/src/tools/utils/DEPS.list b/packages/playwright-core/src/tools/utils/DEPS.list index fad12b908c614..91547c0e3b34b 100644 --- a/packages/playwright-core/src/tools/utils/DEPS.list +++ b/packages/playwright-core/src/tools/utils/DEPS.list @@ -3,3 +3,6 @@ [connect.ts] "strict" + +[extension.ts] +"strict" diff --git a/packages/playwright-core/src/tools/utils/extension.ts b/packages/playwright-core/src/tools/utils/extension.ts new file mode 100644 index 0000000000000..9446b9541d6ed --- /dev/null +++ b/packages/playwright-core/src/tools/utils/extension.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Also pinned via the "key" field in packages/extension/manifest.json. +export const playwrightExtensionId = 'mmlmfjhmonkocbjadbfplnigmagldckm'; + +export const playwrightExtensionInstallUrl = `https://chromewebstore.google.com/detail/playwright-mcp-bridge/${playwrightExtensionId}`; diff --git a/utils/build/build.js b/utils/build/build.js index fddff1ae59ef7..b98b0b1ddb85a 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -593,6 +593,7 @@ steps.push(new EsbuildStep({ filePath('packages/playwright-core/src/tools/cli-client/*.ts'), filePath('packages/playwright-core/src/package.ts'), filePath('packages/playwright-core/src/tools/utils/socketConnection.ts'), + filePath('packages/playwright-core/src/tools/utils/extension.ts'), ], outdir: filePath('packages/playwright-core/lib'), plugins: [dynamicImportToRequirePlugin],