adding support for multiple tabs

This commit is contained in:
Tommy D. Rossi
2025-11-14 15:23:47 +01:00
parent 50b453a64c
commit 6998031438
5 changed files with 421 additions and 182 deletions
+118 -92
View File
@@ -21,7 +21,7 @@ const RELAY_URL = 'ws://localhost:9988/extension';
class SimplifiedExtension { class SimplifiedExtension {
private _connection: RelayConnection | undefined; private _connection: RelayConnection | undefined;
private _connectedTabId: number | null = null; private _connectedTabs: Map<number, string> = new Map();
constructor() { constructor() {
debugLog(`Using relay URL: ${RELAY_URL}`); debugLog(`Using relay URL: ${RELAY_URL}`);
@@ -36,31 +36,22 @@ class SimplifiedExtension {
return; return;
} }
// Toggle: if connected to this tab, disconnect; otherwise connect if (this._connectedTabs.has(tab.id)) {
if (this._connectedTabId === tab.id) { await this._disconnectTab(tab.id);
await this._disconnect();
} else { } else {
await this._connect(tab.id); await this._connectTab(tab.id);
} }
} }
private async _waitForServer(): Promise<WebSocket> { private async _waitForServer(): Promise<void> {
const httpUrl = 'http://localhost:9988'; const httpUrl = 'http://localhost:9988';
while (true) { while (true) {
try { try {
debugLog('Checking if relay server is available...'); debugLog('Checking if relay server is available...');
await fetch(httpUrl, { method: 'HEAD' }); await fetch(httpUrl, { method: 'HEAD' });
debugLog('Server is available, connecting WebSocket...'); debugLog('Server is available');
return;
const socket = new WebSocket(RELAY_URL);
await new Promise<void>((resolve, reject) => {
socket.onopen = () => resolve();
socket.onerror = (e) => reject(e);
setTimeout(() => reject(new Error('Connection timeout')), 2000);
});
debugLog('Connected to relay server');
return socket;
} catch (error: any) { } catch (error: any) {
debugLog(`Server not available, retrying in 1 second...`); debugLog(`Server not available, retrying in 1 second...`);
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
@@ -68,55 +59,103 @@ class SimplifiedExtension {
} }
} }
private async _connect(tabId: number): Promise<void> { private async _connectTab(tabId: number): Promise<void> {
try { try {
debugLog(`Connecting to tab ${tabId}`); debugLog(`=== Starting connection to tab ${tabId} ===`);
// Disconnect from any existing connection
if (this._connection) {
this._connection.close('Switching to new tab');
this._connection = undefined;
}
// Update icon to show connecting state
await this._updateIcon(tabId, 'connecting'); await this._updateIcon(tabId, 'connecting');
await this._waitForServer() if (!this._connection) {
// Connect to WebSocket relay debugLog('No existing connection, creating new relay connection');
const socket = new WebSocket(RELAY_URL); debugLog('Waiting for server at http://localhost:9988...');
await new Promise<void>((resolve, reject) => { await this._waitForServer();
socket.onopen = () => resolve();
socket.onerror = () => reject(new Error('WebSocket connection failed')); debugLog('Server is ready, creating WebSocket connection to:', RELAY_URL);
setTimeout(() => reject(new Error('Connection timeout')), 5000); const socket = new WebSocket(RELAY_URL);
}); debugLog('WebSocket created, initial readyState:', socket.readyState, '(0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)');
await new Promise<void>((resolve, reject) => {
let timeoutFired = false;
const timeout = setTimeout(() => {
timeoutFired = true;
debugLog('=== WebSocket connection TIMEOUT after 5 seconds ===');
debugLog('Final WebSocket readyState:', socket.readyState);
debugLog('WebSocket URL:', socket.url);
debugLog('Socket protocol:', socket.protocol);
reject(new Error('Connection timeout'));
}, 5000);
socket.onopen = () => {
if (timeoutFired) {
debugLog('WebSocket opened but timeout already fired!');
return;
}
debugLog('WebSocket onopen fired! readyState:', socket.readyState);
clearTimeout(timeout);
resolve();
};
socket.onerror = (error) => {
debugLog('WebSocket onerror during connection:', error);
debugLog('Error type:', error.type);
debugLog('Current readyState:', socket.readyState);
if (!timeoutFired) {
clearTimeout(timeout);
reject(new Error('WebSocket connection failed'));
}
};
socket.onclose = (event) => {
debugLog('WebSocket onclose during connection setup:', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
readyState: socket.readyState
});
if (!timeoutFired) {
clearTimeout(timeout);
reject(new Error(`WebSocket closed: ${event.reason || event.code}`));
}
};
debugLog('Event handlers set, waiting for connection...');
});
// Create relay connection debugLog('WebSocket connected successfully, creating RelayConnection instance');
this._connection = new RelayConnection(socket); this._connection = new RelayConnection(socket);
this._connection.onclose = () => { this._connection.onclose = () => {
debugLog('Connection closed'); debugLog('=== Relay connection onclose callback triggered ===');
this._connection = undefined; debugLog('Connected tabs before clearing:', Array.from(this._connectedTabs.keys()));
void this._setConnectedTabId(null); this._connection = undefined;
}; for (const tabId of this._connectedTabs.keys()) {
debugLog('Updating icon to disconnected for tab:', tabId);
void this._updateIcon(tabId, 'disconnected');
}
this._connectedTabs.clear();
debugLog('All tabs cleared');
};
} else {
debugLog('Reusing existing connection');
}
// Set the tab ID (this will attach debugger) debugLog('Calling attachTab for tab:', tabId);
this._connection.setTabId(tabId); const targetInfo = await this._connection.attachTab(tabId);
debugLog('attachTab completed, storing in connectedTabs map');
// Update state this._connectedTabs.set(tabId, targetInfo.targetId);
await this._setConnectedTabId(tabId);
await this._updateIcon(tabId, 'connected');
debugLog(`Successfully connected to tab ${tabId}`); debugLog(`=== Successfully connected to tab ${tabId} ===`);
} catch (error: any) { } catch (error: any) {
debugLog(`Failed to connect: ${error.message}`); debugLog(`=== Failed to connect to tab ${tabId} ===`);
debugLog('Error details:', error);
debugLog('Error stack:', error.stack);
await this._updateIcon(tabId, 'disconnected'); await this._updateIcon(tabId, 'disconnected');
// Show error notification
chrome.action.setBadgeText({ tabId, text: '!' }); chrome.action.setBadgeText({ tabId, text: '!' });
chrome.action.setBadgeBackgroundColor({ tabId, color: '#f44336' }); chrome.action.setBadgeBackgroundColor({ tabId, color: '#f44336' });
chrome.action.setTitle({ tabId, title: `Error: ${error.message}` }); chrome.action.setTitle({ tabId, title: `Error: ${error.message}` });
// Clear error after 3 seconds
setTimeout(() => { setTimeout(() => {
if (this._connectedTabId !== tabId) { if (!this._connectedTabs.has(tabId)) {
chrome.action.setBadgeText({ tabId, text: '' }); chrome.action.setBadgeText({ tabId, text: '' });
chrome.action.setTitle({ tabId, title: 'Click to attach debugger' }); chrome.action.setTitle({ tabId, title: 'Click to attach debugger' });
} }
@@ -124,33 +163,26 @@ class SimplifiedExtension {
} }
} }
private async _disconnect(): Promise<void> { private async _disconnectTab(tabId: number): Promise<void> {
debugLog('Disconnecting'); debugLog(`=== Disconnecting tab ${tabId} ===`);
const tabId = this._connectedTabId; if (!this._connectedTabs.has(tabId)) {
debugLog('Tab not in connectedTabs map, ignoring disconnect');
this._connection?.close('User disconnected'); return;
this._connection = undefined;
await this._setConnectedTabId(null);
if (tabId) {
await this._updateIcon(tabId, 'disconnected');
} }
}
debugLog('Calling detachTab on connection');
private async _setConnectedTabId(tabId: number | null): Promise<void> { this._connection?.detachTab(tabId);
const oldTabId = this._connectedTabId; this._connectedTabs.delete(tabId);
this._connectedTabId = tabId; debugLog('Tab removed from connectedTabs map');
// Clear old tab icon await this._updateIcon(tabId, 'disconnected');
if (oldTabId && oldTabId !== tabId) {
await this._updateIcon(oldTabId, 'disconnected'); debugLog('Connected tabs remaining:', this._connectedTabs.size);
} if (this._connectedTabs.size === 0 && this._connection) {
debugLog('No tabs remaining, closing relay connection');
// Set new tab icon this._connection.close('All tabs disconnected');
if (tabId) { this._connection = undefined;
await this._updateIcon(tabId, 'connected');
} }
} }
@@ -208,23 +240,17 @@ class SimplifiedExtension {
} }
private async _onTabRemoved(tabId: number): Promise<void> { private async _onTabRemoved(tabId: number): Promise<void> {
if (this._connectedTabId !== tabId) { debugLog('Tab removed event for tab:', tabId, 'is connected:', this._connectedTabs.has(tabId));
return; if (!this._connectedTabs.has(tabId)) return;
}
debugLog(`Connected tab ${tabId} was closed, disconnecting`);
debugLog(`Connected tab ${tabId} was closed`); await this._disconnectTab(tabId);
this._connection?.close('Browser tab closed');
this._connection = undefined;
this._connectedTabId = null;
} }
private async _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo): Promise<void> { private async _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo): Promise<void> {
// Update icon for the newly active tab const isConnected = this._connectedTabs.has(activeInfo.tabId);
if (this._connectedTabId === activeInfo.tabId) { debugLog('Tab activated:', activeInfo.tabId, 'is connected:', isConnected);
await this._updateIcon(activeInfo.tabId, 'connected'); await this._updateIcon(activeInfo.tabId, isConnected ? 'connected' : 'disconnected');
} else {
await this._updateIcon(activeInfo.tabId, 'disconnected');
}
} }
} }
+174 -42
View File
@@ -25,62 +25,156 @@ export function debugLog(...args: unknown[]): void {
} }
} }
interface AttachedTab {
debuggee: chrome.debugger.Debuggee;
targetId: string;
sessionId: string;
targetInfo: Protocol.Target.TargetInfo;
}
export class RelayConnection { export class RelayConnection {
private _debuggee: chrome.debugger.Debuggee; private _attachedTabs: Map<number, AttachedTab> = new Map();
private _nextSessionId: number = 1;
private _ws: WebSocket; private _ws: WebSocket;
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void; private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void; private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
private _tabPromise: Promise<void>;
private _tabPromiseResolve!: () => void;
private _closed = false; private _closed = false;
onclose?: () => void; onclose?: () => void;
constructor(ws: WebSocket) { constructor(ws: WebSocket) {
this._debuggee = { };
this._tabPromise = new Promise(resolve => this._tabPromiseResolve = resolve);
this._ws = ws; this._ws = ws;
this._ws.onmessage = this._onMessage.bind(this); this._ws.onmessage = this._onMessage.bind(this);
this._ws.onclose = () => this._onClose(); this._ws.onclose = (event) => {
// Store listeners for cleanup debugLog('WebSocket onclose event:', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean
});
this._onClose();
};
this._ws.onerror = (event) => {
debugLog('WebSocket onerror event:', event);
};
this._eventListener = this._onDebuggerEvent.bind(this); this._eventListener = this._onDebuggerEvent.bind(this);
this._detachListener = this._onDebuggerDetach.bind(this); this._detachListener = this._onDebuggerDetach.bind(this);
chrome.debugger.onEvent.addListener(this._eventListener); chrome.debugger.onEvent.addListener(this._eventListener);
chrome.debugger.onDetach.addListener(this._detachListener); chrome.debugger.onDetach.addListener(this._detachListener);
debugLog('RelayConnection created, WebSocket readyState:', this._ws.readyState);
} }
// Either setTabId or close is called after creating the connection. async attachTab(tabId: number): Promise<Protocol.Target.TargetInfo> {
setTabId(tabId: number): void { const debuggee = { tabId };
this._debuggee = { tabId };
this._tabPromiseResolve(); debugLog('Attaching debugger to tab:', tabId, 'WebSocket state:', this._ws.readyState);
try {
await chrome.debugger.attach(debuggee, '1.3');
debugLog('Debugger attached successfully to tab:', tabId);
} catch (error: any) {
debugLog('ERROR attaching debugger to tab:', tabId, error);
throw error;
}
debugLog('Sending Target.getTargetInfo command for tab:', tabId);
const result = await chrome.debugger.sendCommand(
debuggee,
'Target.getTargetInfo'
) as Protocol.Target.GetTargetInfoResponse;
debugLog('Received targetInfo for tab:', tabId, result.targetInfo);
const targetInfo = result.targetInfo;
const sessionId = `pw-tab-${this._nextSessionId++}`;
this._attachedTabs.set(tabId, {
debuggee,
targetId: targetInfo.targetId,
sessionId,
targetInfo
});
debugLog('Sending Target.attachedToTarget event, WebSocket state:', this._ws.readyState);
this._sendMessage({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId,
targetInfo: {
...targetInfo,
attached: true
},
waitingForDebugger: false
}
}
});
debugLog('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId);
return targetInfo;
}
detachTab(tabId: number): void {
const tab = this._attachedTabs.get(tabId);
if (!tab) return;
debugLog('Detaching tab:', tabId, 'sessionId:', tab.sessionId);
this._sendMessage({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: {
sessionId: tab.sessionId,
targetId: tab.targetId
}
}
});
chrome.debugger.detach(tab.debuggee).catch(() => {});
this._attachedTabs.delete(tabId);
} }
close(message: string): void { close(message: string): void {
debugLog('Closing RelayConnection, reason:', message, 'current state:', this._ws.readyState);
this._ws.close(1000, message); this._ws.close(1000, message);
// ws.onclose is called asynchronously, so we call it here to avoid forwarding
// CDP events to the closed connection.
this._onClose(); this._onClose();
} }
private _onClose() { private _onClose() {
if (this._closed) if (this._closed) {
debugLog('_onClose called but already closed');
return; return;
}
debugLog('Connection closing, attached tabs count:', this._attachedTabs.size);
this._closed = true; this._closed = true;
chrome.debugger.onEvent.removeListener(this._eventListener); chrome.debugger.onEvent.removeListener(this._eventListener);
chrome.debugger.onDetach.removeListener(this._detachListener); chrome.debugger.onDetach.removeListener(this._detachListener);
chrome.debugger.detach(this._debuggee).catch(() => {});
for (const [tabId, tab] of this._attachedTabs) {
debugLog('Detaching debugger from tab:', tabId);
chrome.debugger.detach(tab.debuggee).catch((err) => {
debugLog('Error detaching debugger from tab:', tabId, err);
});
}
this._attachedTabs.clear();
debugLog('Connection closed, calling onclose callback');
this.onclose?.(); this.onclose?.();
} }
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void { private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
if (source.tabId !== this._debuggee.tabId) const tab = this._attachedTabs.get(source.tabId!);
return; if (!tab) return;
debugLog('Forwarding CDP event:', method, params);
const sessionId = source.sessionId; debugLog('Forwarding CDP event:', method, 'from tab:', source.tabId);
this._sendMessage({ this._sendMessage({
method: 'forwardCDPEvent', method: 'forwardCDPEvent',
params: { params: {
sessionId, sessionId: source.sessionId || tab.sessionId,
method, method,
params, params,
}, },
@@ -88,10 +182,16 @@ export class RelayConnection {
} }
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void { private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
if (source.tabId !== this._debuggee.tabId) const tabId = source.tabId;
debugLog('_onDebuggerDetach called for tab:', tabId, 'reason:', reason, 'isAttached:', tabId ? this._attachedTabs.has(tabId) : false);
if (!tabId || !this._attachedTabs.has(tabId)) {
debugLog('Ignoring debugger detach event for untracked tab:', tabId);
return; return;
this.close(`Debugger detached: ${reason}`); }
this._debuggee = { };
debugLog(`Debugger detached from tab ${tabId}: ${reason}`);
this.detachTab(tabId);
} }
private _onMessage(event: MessageEvent): void { private _onMessage(event: MessageEvent): void {
@@ -125,28 +225,52 @@ export class RelayConnection {
private async _handleCommand(message: ExtensionCommandMessage): Promise<any> { private async _handleCommand(message: ExtensionCommandMessage): Promise<any> {
if (message.method === 'attachToTab') { if (message.method === 'attachToTab') {
await this._tabPromise; return {};
debugLog('Attaching debugger to tab:', this._debuggee);
await chrome.debugger.attach(this._debuggee, '1.3');
const result = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo') as Protocol.Target.GetTargetInfoResponse;
return {
targetInfo: result?.targetInfo,
};
} }
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') { if (message.method === 'forwardCDPCommand') {
const forwardParams = message.params as { sessionId?: string; method: string; params?: any }; const { sessionId, method, params } = message.params;
const { sessionId, method, params } = forwardParams;
debugLog('CDP command:', method, params); if (method === 'Target.closeTarget' && params?.targetId) {
for (const [tabId, tab] of this._attachedTabs) {
if (tab.targetId === params.targetId) {
await chrome.tabs.remove(tabId);
return { success: true };
}
}
throw new Error(`Target not found: ${params.targetId}`);
}
let targetTab: AttachedTab | undefined;
for (const [tabId, tab] of this._attachedTabs) {
if (tab.sessionId === sessionId) {
targetTab = tab;
break;
}
}
if (!targetTab) {
if (method === 'Browser.getVersion' || method === 'Target.getTargets') {
targetTab = this._attachedTabs.values().next().value;
}
if (!targetTab) {
throw new Error(`No tab found for sessionId: ${sessionId}`);
}
}
debugLog('CDP command:', method, 'for tab:', targetTab.debuggee.tabId);
const debuggerSession: chrome.debugger.DebuggerSession = { const debuggerSession: chrome.debugger.DebuggerSession = {
...this._debuggee, ...targetTab.debuggee,
sessionId, sessionId: sessionId !== targetTab.sessionId ? sessionId : undefined,
}; };
return await chrome.debugger.sendCommand( return await chrome.debugger.sendCommand(
debuggerSession, debuggerSession,
method, method,
params params
); );
} }
} }
@@ -161,7 +285,15 @@ export class RelayConnection {
} }
private _sendMessage(message: any): void { private _sendMessage(message: any): void {
if (this._ws.readyState === WebSocket.OPEN) if (this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify(message)); try {
this._ws.send(JSON.stringify(message));
debugLog('Message sent successfully, type:', message.method || 'response');
} catch (error: any) {
debugLog('ERROR sending message:', error, 'message type:', message.method || 'response');
}
} else {
debugLog('Cannot send message, WebSocket not open. State:', this._ws.readyState, 'message type:', message.method || 'response');
}
} }
} }
+26 -2
View File
@@ -3,6 +3,30 @@ currently the extension and server code assume there is only one tab attached to
--- ---
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 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 in background.ts. when this happens we should also send also a cdp message from the extension for Target.attachedToTarget after we are connected. so playwright knows of this new page and will list it in browser.pages()
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. now see where we already are sending Target.attachedToTarget. this is now sent after setAutoAttach, meaning as soon as playwright connects. let's change this. we should not do anything during setAutoAttach, leave it empty for now.
at the end the extension should basically:
- let the server know of new pages only when the user clicks one
- support multiple tabs, the extension icon should not be gray if a tab is in the array of tabs. this icon state should be switched also during onActivate so that we know when user changes current tab
- we should remove the code that currently closes the previous tab debugger session. this was there because it assumed there can only be one session at a time
- simplify overall code. for example removing methods that are only used once and inlining them in the parent method.
- remove a tab from the array when playwright sends command Target.closeTarget, based on passed targetId
notice that we can know what a command and response is for which target based on sessionId or targetId in the payloads. we can know which messages have these thanks to the d
devtools-protocol package types
at the end typecheck. try not to use as any or other as.
remember: each tab will have different sessionId and targetId. based on these we can associate each CDP command to a specific tab.
playwright usually will basically discover pages thanks to our messages Target.attachedToTarget. those messages will let playwright know what is the sessionId for each target.
playwright will use Target.createTarget to create a new page. we will return a targetId and right after trigger a Target.attachedToTarget to let playwright know also the session to use.
remember that we must use same id field for CDP responses. to associate a response to a CDP command, using the same id. this let us send interpolated and concurrent messages
+3 -1
View File
@@ -7,6 +7,8 @@ async function main() {
const contexts = browser.contexts(); const contexts = browser.contexts();
console.log(`Found ${contexts.length} browser context(s)`); console.log(`Found ${contexts.length} browser context(s)`);
// Sleep 200 ms
await new Promise(resolve => setTimeout(resolve, 200));
for (const context of contexts) { for (const context of contexts) {
const pages = context.pages(); const pages = context.pages();
console.log(`Context has ${pages.length} page(s):`); console.log(`Context has ${pages.length} page(s):`);
@@ -26,6 +28,7 @@ async function main() {
console.log(`Browser log: [${msg.type()}] ${msg.text()}`); console.log(`Browser log: [${msg.type()}] ${msg.text()}`);
}); });
console.log(`running eval`)
// Evaluate a sum in the browser and log something from inside the browser context // Evaluate a sum in the browser and log something from inside the browser context
const sumResult = await page.evaluate(() => { const sumResult = await page.evaluate(() => {
console.log('Logging from inside browser context!'); console.log('Logging from inside browser context!');
@@ -35,7 +38,6 @@ async function main() {
} }
} }
await browser.close();
} }
main() main()
+100 -45
View File
@@ -40,6 +40,12 @@ import type { CDPCommand, CDPResponse, CDPEvent, Protocol } from '../cdp-types.j
const debugLogger = debug('pw:mcp:relay'); const debugLogger = debug('pw:mcp:relay');
interface ConnectedTarget {
sessionId: string;
targetId: string;
targetInfo: Protocol.Target.TargetInfo;
}
export class CDPRelayServer { export class CDPRelayServer {
private _wsHost: string; private _wsHost: string;
@@ -48,12 +54,7 @@ export class CDPRelayServer {
private _wss: WebSocketServer; private _wss: WebSocketServer;
private _playwrightConnection: WebSocket | null = null; private _playwrightConnection: WebSocket | null = null;
private _extensionConnection: ExtensionConnection | null = null; private _extensionConnection: ExtensionConnection | null = null;
private _connectedTabInfo: { private _connectedTargets: Map<string, ConnectedTarget> = new Map();
targetInfo: any;
// Page sessionId that should be used by this connection.
sessionId: string;
} | undefined;
private _nextSessionId: number = 1;
private _extensionConnectionPromise!: ManualPromise<void>; private _extensionConnectionPromise!: ManualPromise<void>;
constructor(server: http.Server, ) { constructor(server: http.Server, ) {
@@ -119,8 +120,7 @@ export class CDPRelayServer {
if (this._playwrightConnection !== ws) if (this._playwrightConnection !== ws)
return; return;
this._playwrightConnection = null; this._playwrightConnection = null;
this._closeExtensionConnection('Playwright client disconnected'); debugLogger('Playwright WebSocket closed - extension stays connected');
debugLogger('Playwright WebSocket closed');
}); });
ws.on('error', error => { ws.on('error', error => {
debugLogger('Playwright WebSocket error:', error); debugLogger('Playwright WebSocket error:', error);
@@ -135,7 +135,7 @@ export class CDPRelayServer {
} }
private _resetExtensionConnection() { private _resetExtensionConnection() {
this._connectedTabInfo = undefined; this._connectedTargets.clear();
this._extensionConnection = null; this._extensionConnection = null;
this._extensionConnectionPromise = new ManualPromise(); this._extensionConnectionPromise = new ManualPromise();
void this._extensionConnectionPromise.catch(logUnhandledError); void this._extensionConnectionPromise.catch(logUnhandledError);
@@ -167,23 +167,52 @@ export class CDPRelayServer {
private _handleExtensionMessage(message: ExtensionEventMessage) { private _handleExtensionMessage(message: ExtensionEventMessage) {
if (message.method === 'forwardCDPEvent') { if (message.method === 'forwardCDPEvent') {
const sessionId = message.params.sessionId || this._connectedTabInfo?.sessionId; const { method, params, sessionId } = message.params;
this._sendToPlaywright({
sessionId, if (method === 'Target.attachedToTarget') {
method: message.params.method, const targetParams = params as Protocol.Target.AttachedToTargetEvent;
params: message.params.params this._connectedTargets.set(targetParams.sessionId, {
} as CDPEvent); sessionId: targetParams.sessionId,
targetId: targetParams.targetInfo.targetId,
targetInfo: targetParams.targetInfo
});
debugLogger('\x1b[33m← Extension:\x1b[0m', `Target.attachedToTarget sessionId=${targetParams.sessionId}, targetId=${targetParams.targetInfo.targetId}`);
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: targetParams
} as CDPEvent);
} else if (method === 'Target.detachedFromTarget') {
const detachParams = params as Protocol.Target.DetachedFromTargetEvent;
this._connectedTargets.delete(detachParams.sessionId);
debugLogger('\x1b[33m← Extension:\x1b[0m', `Target.detachedFromTarget sessionId=${detachParams.sessionId}`);
this._sendToPlaywright({
method: 'Target.detachedFromTarget',
params: detachParams
} as CDPEvent);
} else {
this._sendToPlaywright({
sessionId,
method,
params
} as CDPEvent);
}
} }
} }
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> { private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
debugLogger('← Playwright:', `${message.method} (id=${message.id})`); debugLogger('\x1b[36m← Playwright:\x1b[0m', `${message.method} (id=${message.id})`);
const { id, sessionId, method, params } = message; const { id, sessionId, method, params } = message;
try { try {
const result = await this._handleCDPCommand(method, params, sessionId); const result = await this._handleCDPCommand(method, params, sessionId);
this._sendToPlaywright({ id, sessionId, result }); this._sendToPlaywright({ id, sessionId, result });
} catch (e) { } catch (e) {
debugLogger('Error in the extension:', e); debugLogger('\x1b[31mError in the extension:\x1b[0m', e);
this._sendToPlaywright({ this._sendToPlaywright({
id, id,
sessionId, sessionId,
@@ -207,33 +236,61 @@ export class CDPRelayServer {
return { }; return { };
} }
case 'Target.setAutoAttach': { case 'Target.setAutoAttach': {
// Forward child session handling. if (sessionId) {
if (sessionId)
break; break;
// 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.'); debugLogger('Target.setAutoAttach received (manual attach mode)');
const { targetInfo } = await this._extensionConnection.send({ method: 'attachToTab' }); debugLogger('Sending Target.attachedToTarget events for existing targets:', this._connectedTargets.size);
this._connectedTabInfo = {
targetInfo, for (const target of this._connectedTargets.values()) {
sessionId: `pw-tab-${this._nextSessionId++}`, debugLogger('Sending Target.attachedToTarget for sessionId:', target.sessionId, 'targetId:', target.targetId);
}; this._sendToPlaywright({
debugLogger('Simulating auto-attach'); method: 'Target.attachedToTarget',
this._sendToPlaywright({ params: {
method: 'Target.attachedToTarget', sessionId: target.sessionId,
params: { targetInfo: {
sessionId: this._connectedTabInfo.sessionId, ...target.targetInfo,
targetInfo: { attached: true
...this._connectedTabInfo.targetInfo, },
attached: true, waitingForDebugger: false
}, }
waitingForDebugger: false } as CDPEvent);
} }
} satisfies CDPEvent);
return { }; return {};
} }
case 'Target.getTargetInfo': { case 'Target.getTargetInfo': {
return this._connectedTabInfo?.targetInfo; const targetId = params?.targetId;
if (targetId) {
for (const target of this._connectedTargets.values()) {
if (target.targetId === targetId) {
return { targetInfo: target.targetInfo };
}
}
}
if (sessionId) {
const target = this._connectedTargets.get(sessionId);
if (target) {
return { targetInfo: target.targetInfo };
}
}
const firstTarget = this._connectedTargets.values().next().value;
return { targetInfo: firstTarget?.targetInfo };
}
case 'Target.getTargets': {
return {
targetInfos: Array.from(this._connectedTargets.values()).map(t => ({
...t.targetInfo,
attached: true,
}))
};
}
case 'Target.closeTarget': {
break;
} }
} }
return await this._forwardToExtension(method, params, sessionId); return await this._forwardToExtension(method, params, sessionId);
@@ -242,9 +299,7 @@ export class CDPRelayServer {
private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> { private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> {
if (!this._extensionConnection) if (!this._extensionConnection)
throw new Error('Extension not connected'); 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({ return await this._extensionConnection.send({
method: 'forwardCDPCommand', method: 'forwardCDPCommand',
params: { sessionId, method, params } params: { sessionId, method, params }
@@ -255,7 +310,7 @@ export class CDPRelayServer {
const logMessage = 'method' in message && message.method const logMessage = 'method' in message && message.method
? message.method ? message.method
: `response(id=${'id' in message ? message.id : 'unknown'})`; : `response(id=${'id' in message ? message.id : 'unknown'})`;
debugLogger('→ Playwright:', logMessage); debugLogger('\x1b[32m→ Playwright:\x1b[0m', logMessage);
this._playwrightConnection?.send(JSON.stringify(message)); this._playwrightConnection?.send(JSON.stringify(message));
} }
} }