From 44514f00c873e7be50c887d1abbbe56eaddc59df Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Wed, 4 Feb 2026 16:43:09 +0100 Subject: [PATCH] feat: add multi-browser support - Extension connections are now tracked per-connection with isolated state - Each connection has its own connectedTargets, pendingRequests, messageId - Sessions are bound to specific extensionId via SessionMetadata - New /extensions/status endpoint for listing all connected browsers - CLI session new shows browser selection when multiple connected - Browser detection via navigator.userAgentData (Ghost, Brave, Edge, etc) - Added identity permission for Chrome profile email detection --- extension/manifest.json | 2 +- extension/src/background.ts | 97 +++++++- playwriter/src/cdp-relay.ts | 477 ++++++++++++++++++++++++++---------- playwriter/src/cli.ts | 234 ++++++++++++++---- playwriter/src/executor.ts | 69 +++++- playwriter/src/utils.ts | 24 +- 6 files changed, 719 insertions(+), 184 deletions(-) diff --git a/extension/manifest.json b/extension/manifest.json index f44e97a..3141fb1 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -3,7 +3,7 @@ "name": "Playwriter", "version": "0.0.69", "description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.", - "permissions": ["debugger", "tabGroups", "contextMenus", "tabs", "tabCapture", "offscreen"], + "permissions": ["debugger", "tabGroups", "contextMenus", "tabs", "tabCapture", "offscreen", "identity", "identity.email"], "host_permissions": [""], "background": { "service_worker": "background.js", diff --git a/extension/src/background.ts b/extension/src/background.ts index a794f8f..97c2fe1 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -14,13 +14,83 @@ import { cleanupRecordingForTab, } from './recording' -const RELAY_PORT = process.env.PLAYWRITER_PORT -const RELAY_URL = `ws://127.0.0.1:${RELAY_PORT}/extension` +const RELAY_HOST = '127.0.0.1' +const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988 + +type NavigatorWithUaData = Navigator & { + userAgentData?: { + brands: Array<{ brand: string; version: string }> + } +} + +type ExtensionIdentity = { + browser: string + email: string + id: string +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } +async function detectBrowserName(): Promise { + if ((chrome as unknown as { ghostPublicAPI?: unknown }).ghostPublicAPI) { + return 'Ghost' + } + + const navigatorWithUaData = navigator as NavigatorWithUaData + const brands = navigatorWithUaData.userAgentData?.brands + if (brands && brands.length > 0) { + const brandNames = brands.map((brand) => { + return brand.brand.trim().toLowerCase() + }) + + if (brandNames.some((brand) => brand === 'brave')) return 'Brave' + if (brandNames.some((brand) => brand === 'microsoft edge')) return 'Edge' + if (brandNames.some((brand) => brand === 'opera')) return 'Opera' + if (brandNames.some((brand) => brand === 'vivaldi')) return 'Vivaldi' + if (brandNames.some((brand) => brand === 'google chrome')) return 'Chrome' + if (brandNames.some((brand) => brand === 'chromium')) return 'Chromium' + } + + const ua = navigator.userAgent.toLowerCase() + if (ua.includes('edg/')) return 'Edge' + if (ua.includes('opr/')) return 'Opera' + if (ua.includes('vivaldi')) return 'Vivaldi' + if (ua.includes('brave')) return 'Brave' + if (ua.includes('chrome')) return 'Chrome' + return 'Chromium' +} + +let identityPromise: Promise | null = null + +async function getExtensionIdentity(): Promise { + if (identityPromise) { + return identityPromise + } + + identityPromise = (async () => { + const browser = await detectBrowserName() + try { + const info = await chrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }) + return { + browser, + email: info.email || '', + id: info.id || '', + } + } catch { + return { + browser, + email: '', + id: '', + } + } + })() + + return identityPromise +} + let childSessions: Map = new Map() let nextSessionId = 1 let tabGroupQueue: Promise = Promise.resolve() @@ -106,14 +176,14 @@ class ConnectionManager { } private async connect(): Promise { - logger.debug(`Waiting for server at http://127.0.0.1:${RELAY_PORT}...`) + logger.debug(`Waiting for server at http://${RELAY_HOST}:${RELAY_PORT}...`) // Retry for up to 5 seconds with 1s intervals, then give up (maintain loop will retry later) // Using fewer attempts since maintainLoop retries every 3 seconds anyway const maxAttempts = 5 for (let attempt = 0; attempt < maxAttempts; attempt++) { try { - await fetch(`http://127.0.0.1:${RELAY_PORT}`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) + await fetch(`http://${RELAY_HOST}:${RELAY_PORT}`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) logger.debug('Server is available') break } catch { @@ -125,8 +195,19 @@ class ConnectionManager { } } - logger.debug('Creating WebSocket connection to:', RELAY_URL) - const socket = new WebSocket(RELAY_URL) + const identity = await getExtensionIdentity() + const relayUrl = new URL(`ws://${RELAY_HOST}:${RELAY_PORT}/extension`) + if (identity.browser) { + relayUrl.searchParams.set('browser', identity.browser) + } + if (identity.email) { + relayUrl.searchParams.set('email', identity.email) + } + if (identity.id) { + relayUrl.searchParams.set('id', identity.id) + } + logger.debug('Creating WebSocket connection to:', relayUrl) + const socket = new WebSocket(relayUrl.toString()) await new Promise((resolve, reject) => { let settled = false @@ -389,7 +470,7 @@ class ConnectionManager { // Slot is free when: no extension connected, OR connected but no active tabs. if (store.getState().connectionState === 'extension-replaced') { try { - const response = await fetch(`http://127.0.0.1:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) }) + const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) }) const data = await response.json() as { connected: boolean; activeTargets: number } const slotAvailable = !data.connected || data.activeTargets === 0 if (slotAvailable) { @@ -1369,7 +1450,7 @@ store.subscribe((state, prevState) => { } }) -logger.debug(`Using relay URL: ${RELAY_URL}`) +logger.debug(`Using relay host: ${RELAY_HOST}, port: ${RELAY_PORT}`) // Memory monitoring - helps debug service worker termination issues let lastMemoryUsage = 0 diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index b3bd5fe..5af2dcc 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -54,6 +54,23 @@ function isRestrictedTarget(targetInfo: Protocol.Target.TargetInfo): boolean { type PlaywrightClient = { id: string ws: WSContext + extensionId: string | null +} + +type ExtensionInfo = { + browser?: string + email?: string + id?: string +} + +type ExtensionConnection = { + id: string + ws: WSContext + info: ExtensionInfo + connectedTargets: Map + pendingRequests: Map void; reject: (error: Error) => void }> + messageId: number + pingInterval: ReturnType | null } @@ -77,36 +94,49 @@ export async function startPlayWriterCDPRelayServer({ cdpLogger?: CdpLogger } = {}): Promise { const emitter = new EventEmitter() - const connectedTargets = new Map() + const extensionConnections = new Map() const resolvedCdpLogger = cdpLogger || createCdpLogger() const logCdpJson = (entry: CdpLogEntry) => { resolvedCdpLogger.log(entry) } const playwrightClients = new Map() - let extensionWs: WSContext | null = null - const extensionPendingRequests = new Map void - reject: (error: Error) => void - }>() - let extensionMessageId = 0 - let extensionPingInterval: ReturnType | null = null + const getDefaultExtensionId = (): string | null => { + return extensionConnections.keys().next().value || null + } - function startExtensionPing() { - if (extensionPingInterval) { - clearInterval(extensionPingInterval) + const getExtensionConnection = (extensionId?: string | null): ExtensionConnection | null => { + if (extensionId && extensionConnections.has(extensionId)) { + return extensionConnections.get(extensionId) || null } - extensionPingInterval = setInterval(() => { - extensionWs?.send(JSON.stringify({ method: 'ping' })) + const fallbackId = getDefaultExtensionId() + if (fallbackId) { + return extensionConnections.get(fallbackId) || null + } + return null + } + + const startExtensionPing = (extensionId: string): void => { + const connection = extensionConnections.get(extensionId) + if (!connection) { + return + } + if (connection.pingInterval) { + clearInterval(connection.pingInterval) + } + connection.pingInterval = setInterval(() => { + connection.ws.send(JSON.stringify({ method: 'ping' })) }, 5000) } - function stopExtensionPing() { - if (extensionPingInterval) { - clearInterval(extensionPingInterval) - extensionPingInterval = null + const stopExtensionPing = (extensionId: string): void => { + const connection = extensionConnections.get(extensionId) + if (!connection || !connection.pingInterval) { + return } + clearInterval(connection.pingInterval) + connection.pingInterval = null } function logCdpMessage({ @@ -179,11 +209,13 @@ export async function startPlayWriterCDPRelayServer({ function sendToPlaywright({ message, clientId, - source = 'extension' + source = 'extension', + extensionId, }: { message: CDPResponseBase | CDPEventBase clientId?: string source?: 'extension' | 'server' + extensionId?: string | null }) { const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } @@ -231,9 +263,11 @@ export async function startPlayWriterCDPRelayServer({ safeSend(client) } } else { - // Copy the clients array to avoid issues if a client disconnects during iteration const clients = Array.from(playwrightClients.values()) for (const client of clients) { + if (extensionId && client.extensionId !== extensionId) { + continue + } safeSend(client) } } @@ -257,12 +291,23 @@ export async function startPlayWriterCDPRelayServer({ return { method: record.method, sessionId, params: record.params } } - async function sendToExtension({ method, params, timeout = 30000 }: { method: string; params?: unknown; timeout?: number }): Promise { - if (!extensionWs) { + async function sendToExtension({ + extensionId, + method, + params, + timeout = 30000, + }: { + extensionId?: string | null + method: string + params?: unknown + timeout?: number + }): Promise { + const connection = getExtensionConnection(extensionId) + if (!connection) { throw new Error('Extension not connected') } - const id = ++extensionMessageId + const id = ++connection.messageId const message = { id, method, params } const forwardCdpParams = method === 'forwardCDPCommand' ? getForwardCdpParams(params) : undefined @@ -278,15 +323,15 @@ export async function startPlayWriterCDPRelayServer({ }) } - extensionWs.send(JSON.stringify(message)) + connection.ws.send(JSON.stringify(message)) return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { - extensionPendingRequests.delete(id) + connection.pendingRequests.delete(id) reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`)) }, timeout) - extensionPendingRequests.set(id, { + connection.pendingRequests.set(id, { resolve: (result) => { clearTimeout(timeoutId) resolve(result) @@ -299,48 +344,76 @@ export async function startPlayWriterCDPRelayServer({ }) } - // Recording relay for screen recording functionality - const recordingRelay = new RecordingRelay( - sendToExtension, - () => extensionWs !== null, - logger, - ) + const recordingRelays = new Map() + + const getRecordingRelay = (extensionId?: string | null): RecordingRelay | null => { + const connection = getExtensionConnection(extensionId) + if (!connection) { + return null + } + if (!recordingRelays.has(connection.id)) { + recordingRelays.set( + connection.id, + new RecordingRelay( + (params) => sendToExtension({ extensionId: connection.id, ...params }), + () => extensionConnections.has(connection.id), + logger, + ) + ) + } + return recordingRelays.get(connection.id) || null + } // Auto-create initial tab when PLAYWRITER_AUTO_ENABLE is set and no targets exist. // This allows Playwright to connect and immediately have a page to work with. - async function maybeAutoCreateInitialTab(): Promise { + async function maybeAutoCreateInitialTab(extensionId: string): Promise { if (!process.env.PLAYWRITER_AUTO_ENABLE) { return } - if (!extensionWs) { + const connection = getExtensionConnection(extensionId) + if (!connection) { return } - if (connectedTargets.size > 0) { + if (connection.connectedTargets.size > 0) { return } try { logger?.log(pc.blue('Auto-creating initial tab for Playwright client')) - const result = await sendToExtension({ method: 'createInitialTab', timeout: 10000 }) as { + const result = await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 }) as { success: boolean tabId: number sessionId: string targetInfo: Protocol.Target.TargetInfo } if (result.success && result.sessionId && result.targetInfo) { - connectedTargets.set(result.sessionId, { + connection.connectedTargets.set(result.sessionId, { sessionId: result.sessionId, targetId: result.targetInfo.targetId, targetInfo: result.targetInfo }) - logger?.log(pc.blue(`Auto-created tab, now have ${connectedTargets.size} targets, url: ${result.targetInfo.url}`)) + logger?.log(pc.blue(`Auto-created tab, now have ${connection.connectedTargets.size} targets, url: ${result.targetInfo.url}`)) } } catch (e) { logger?.error('Failed to auto-create initial tab:', e) } } - async function routeCdpCommand({ method, params, sessionId, source }: { method: string; params: any; sessionId?: string; source?: 'playwriter' }) { + async function routeCdpCommand({ + extensionId, + method, + params, + sessionId, + source, + }: { + extensionId: string | null + method: string + params: any + sessionId?: string + source?: 'playwriter' + }) { + const extension = getExtensionConnection(extensionId) + const connectedTargets = extension?.connectedTargets || new Map() switch (method) { case 'Browser.getVersion': { return { @@ -363,10 +436,13 @@ export async function startPlayWriterCDPRelayServer({ if (sessionId) { break } - await maybeAutoCreateInitialTab() + if (extension) { + await maybeAutoCreateInitialTab(extension.id) + } // Forward auto-attach so Chrome emits iframe Target.attachedToTarget events. // Playwright relies on these (with parentFrameId) when reconnecting over CDP. await sendToExtension({ + extensionId: extension?.id || extensionId, method: 'forwardCDPCommand', params: { method, params, source } }) @@ -427,6 +503,7 @@ export async function startPlayWriterCDPRelayServer({ case 'Target.createTarget': { return await sendToExtension({ + extensionId: extension?.id || extensionId, method: 'forwardCDPCommand', params: { method, params, source } }) @@ -434,6 +511,7 @@ export async function startPlayWriterCDPRelayServer({ case 'Target.closeTarget': { return await sendToExtension({ + extensionId: extension?.id || extensionId, method: 'forwardCDPCommand', params: { method, params, source } }) @@ -442,6 +520,7 @@ export async function startPlayWriterCDPRelayServer({ // Ghost Browser API - forward to extension for chrome.ghostPublicAPI/ghostProxies/projects case 'ghost-browser': { return await sendToExtension({ + extensionId: extension?.id || extensionId, method: 'ghost-browser', params }) @@ -472,6 +551,7 @@ export async function startPlayWriterCDPRelayServer({ }) const result = await sendToExtension({ + extensionId: extension?.id || extensionId, method: 'forwardCDPCommand', params: { sessionId, method, params, source } }) @@ -483,6 +563,7 @@ export async function startPlayWriterCDPRelayServer({ } return await sendToExtension({ + extensionId: extension?.id || extensionId, method: 'forwardCDPCommand', params: { sessionId, method, params, source } }) @@ -521,12 +602,31 @@ export async function startPlayWriterCDPRelayServer({ }) app.get('/extension/status', (c) => { + const defaultExtension = getExtensionConnection(null) + const connected = extensionConnections.size > 0 + const activeTargets = defaultExtension?.connectedTargets.size || 0 + const info = defaultExtension?.info + return c.json({ - connected: extensionWs !== null, - activeTargets: connectedTargets.size + connected, + activeTargets, + browser: info?.browser || null, + profile: info ? { email: info.email || '', id: info.id || '' } : null, }) }) + app.get('/extensions/status', (c) => { + const extensions = Array.from(extensionConnections.values()).map((extension) => { + return { + extensionId: extension.id, + browser: extension.info.browser || null, + profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null, + activeTargets: extension.connectedTargets.size, + } + }) + return c.json({ extensions }) + }) + // CDP Discovery Endpoints - Standard Chrome DevTools Protocol HTTP API // Allows tools like Playwright to discover the WebSocket URL via http://host:port // Spec: https://chromium.googlesource.com/chromium/src/+/main/content/browser/devtools/devtools_http_handler.cc @@ -548,8 +648,9 @@ export async function startPlayWriterCDPRelayServer({ }) .on(['GET', 'PUT'], '/json/list', (c) => { const wsUrl = getCdpWsUrl(c) + const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map() return c.json( - Array.from(connectedTargets.values()).map(t => ({ + Array.from(defaultTargets.values()).map(t => ({ id: t.targetId, type: t.targetInfo.type, title: t.targetInfo.title, @@ -562,8 +663,9 @@ export async function startPlayWriterCDPRelayServer({ }) .on(['GET', 'PUT'], '/json/list/', (c) => { const wsUrl = getCdpWsUrl(c) + const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map() return c.json( - Array.from(connectedTargets.values()).map(t => ({ + Array.from(defaultTargets.values()).map(t => ({ id: t.targetId, type: t.targetInfo.type, title: t.targetInfo.title, @@ -576,8 +678,9 @@ export async function startPlayWriterCDPRelayServer({ }) .on(['GET', 'PUT'], '/json', (c) => { const wsUrl = getCdpWsUrl(c) + const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map() return c.json( - Array.from(connectedTargets.values()).map(t => ({ + Array.from(defaultTargets.values()).map(t => ({ id: t.targetId, type: t.targetInfo.type, title: t.targetInfo.title, @@ -590,8 +693,9 @@ export async function startPlayWriterCDPRelayServer({ }) .on(['GET', 'PUT'], '/json/', (c) => { const wsUrl = getCdpWsUrl(c) + const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map() return c.json( - Array.from(connectedTargets.values()).map(t => ({ + Array.from(defaultTargets.values()).map(t => ({ id: t.targetId, type: t.targetInfo.type, title: t.targetInfo.title, @@ -646,6 +750,10 @@ export async function startPlayWriterCDPRelayServer({ return next() }, upgradeWebSocket((c) => { const clientId = c.req.param('clientId') || 'default' + const url = new URL(c.req.url, 'http://localhost') + const requestedExtensionId = url.searchParams.get('extensionId') + const resolvedExtension = getExtensionConnection(requestedExtensionId) + const clientExtensionId = resolvedExtension?.id || null return { async onOpen(_event, ws) { @@ -656,8 +764,10 @@ export async function startPlayWriterCDPRelayServer({ } // Add client first so it can receive Target.attachedToTarget events - playwrightClients.set(clientId, { id: clientId, ws }) - logger?.log(pc.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionWs}) (${connectedTargets.size} pages)`)) + playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId || null }) + const extensionConnection = getExtensionConnection(clientExtensionId) + const targetCount = extensionConnection?.connectedTargets.size || 0 + logger?.log(pc.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`)) }, async onMessage(event, ws) { @@ -688,7 +798,8 @@ export async function startPlayWriterCDPRelayServer({ emitter.emit('cdp:command', { clientId, command: message }) - if (!extensionWs) { + const extensionConnection = getExtensionConnection(clientExtensionId) + if (!extensionConnection) { sendToPlaywright({ message: { id, @@ -701,10 +812,10 @@ export async function startPlayWriterCDPRelayServer({ } try { - const result: any = await routeCdpCommand({ method, params, sessionId, source }) + const result: any = await routeCdpCommand({ extensionId: extensionConnection.id, method, params, sessionId, source }) if (method === 'Target.setAutoAttach' && !sessionId) { - for (const target of connectedTargets.values()) { + for (const target of extensionConnection.connectedTargets.values()) { // Skip restricted targets (extensions, chrome:// URLs, non-page types) if (isRestrictedTarget(target.targetInfo)) { continue @@ -733,7 +844,7 @@ export async function startPlayWriterCDPRelayServer({ } if (method === 'Target.setDiscoverTargets' && (params as any)?.discover) { - for (const target of connectedTargets.values()) { + for (const target of extensionConnection.connectedTargets.values()) { // Skip restricted targets (extensions, chrome:// URLs, non-page types) if (isRestrictedTarget(target.targetInfo)) { continue @@ -761,7 +872,7 @@ export async function startPlayWriterCDPRelayServer({ if (method === 'Target.attachToTarget' && result?.sessionId) { const targetId = params?.targetId - const target = Array.from(connectedTargets.values()).find(t => t.targetId === targetId) + const target = Array.from(extensionConnection.connectedTargets.values()).find(t => t.targetId === targetId) if (target) { const attachedPayload = { method: 'Target.attachedToTarget', @@ -812,6 +923,17 @@ export async function startPlayWriterCDPRelayServer({ } })) + const getExtensionInfoFromRequest = (c: { req: { query: (name: string) => string | undefined } }): ExtensionInfo => { + const browser = c.req.query('browser') + const email = c.req.query('email') + const id = c.req.query('id') + return { + browser: browser || undefined, + email: email || undefined, + id: id || undefined, + } + } + app.get('/extension', (c, next) => { // 1. Host Validation: The extension endpoint must ONLY be accessed from localhost. // This prevents attackers on the network from hijacking the browser session @@ -841,44 +963,38 @@ export async function startPlayWriterCDPRelayServer({ } return next() - }, upgradeWebSocket(() => { + }, upgradeWebSocket((c) => { + const incomingExtensionInfo = getExtensionInfoFromRequest(c) + const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` return { onOpen(_event, ws) { - if (extensionWs) { - // Only allow replacement if the existing extension has no active tabs. - // This prevents a new extension from hijacking an actively-used session. - // If the existing extension is idle (no tabs), allow the new one to take over. - if (connectedTargets.size > 0) { - logger?.log(pc.yellow(`Rejecting new extension - existing one has ${connectedTargets.size} active tab(s)`)) - ws.close(4002, 'Extension Already In Use') - return - } - - logger?.log(pc.yellow('Replacing idle extension connection (no active tabs)')) - extensionWs.close(4001, 'Extension Replaced') - - // Clear state from the old connection to prevent leaks - for (const pending of extensionPendingRequests.values()) { - pending.reject(new Error('Extension connection replaced')) - } - extensionPendingRequests.clear() - - for (const client of playwrightClients.values()) { - client.ws.close(1000, 'Extension Replaced') - } - playwrightClients.clear() + const connection: ExtensionConnection = { + id: connectionId, + ws, + info: incomingExtensionInfo, + connectedTargets: new Map(), + pendingRequests: new Map(), + messageId: 0, + pingInterval: null, } - - extensionWs = ws - startExtensionPing() - logger?.log('Extension connected with clean state') + extensionConnections.set(connectionId, connection) + startExtensionPing(connectionId) + logger?.log(`Extension connected (${connectionId})`) }, async onMessage(event, ws) { + const connection = extensionConnections.get(connectionId) + if (!connection) { + ws.close(1000, 'Extension not registered') + return + } // Handle binary data (recording chunks) if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) { const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data) - recordingRelay.handleBinaryData(buffer) + const relay = getRecordingRelay(connectionId) + if (relay) { + relay.handleBinaryData(buffer) + } return } @@ -892,13 +1008,13 @@ export async function startPlayWriterCDPRelayServer({ } if (message.id !== undefined) { - const pending = extensionPendingRequests.get(message.id) + const pending = connection.pendingRequests.get(message.id) if (!pending) { logger?.log('Unexpected response with id:', message.id) return } - extensionPendingRequests.delete(message.id) + connection.pendingRequests.delete(message.id) if (message.error) { pending.reject(new Error(message.error)) @@ -914,9 +1030,15 @@ export async function startPlayWriterCDPRelayServer({ const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`) logFunc?.(prefix, ...args) } else if (message.method === 'recordingData') { - recordingRelay.handleRecordingData(message as RecordingDataMessage) + const relay = getRecordingRelay(connectionId) + if (relay) { + relay.handleRecordingData(message as RecordingDataMessage) + } } else if (message.method === 'recordingCancelled') { - recordingRelay.handleRecordingCancelled(message as RecordingCancelledMessage) + const relay = getRecordingRelay(connectionId) + if (relay) { + relay.handleRecordingCancelled(message as RecordingCancelledMessage) + } } else { const extensionEvent = message as ExtensionEventMessage @@ -949,6 +1071,7 @@ export async function startPlayWriterCDPRelayServer({ if (isRestrictedTarget(targetParams.targetInfo)) { if (targetParams.waitingForDebugger && targetParams.sessionId) { void sendToExtension({ + extensionId: connectionId, method: 'forwardCDPCommand', params: { sessionId: targetParams.sessionId, @@ -971,10 +1094,10 @@ export async function startPlayWriterCDPRelayServer({ logger?.log(pc.yellow('[Extension] Target.attachedToTarget full payload:'), JSON.stringify({ method, params: targetParams, sessionId })) // Check if we already sent this target to clients (e.g., from Target.setAutoAttach response) - const alreadyConnected = connectedTargets.has(targetParams.sessionId) + const alreadyConnected = connection.connectedTargets.has(targetParams.sessionId) // Always update our local state with latest target info - connectedTargets.set(targetParams.sessionId, { + connection.connectedTargets.set(targetParams.sessionId, { sessionId: targetParams.sessionId, targetId: targetParams.targetInfo.targetId, targetInfo: targetParams.targetInfo @@ -987,25 +1110,27 @@ export async function startPlayWriterCDPRelayServer({ method: 'Target.attachedToTarget', params: targetParams } as CDPEventBase, - source: 'extension' + source: 'extension', + extensionId: connectionId, }) } } else if (method === 'Target.detachedFromTarget') { const detachParams = params as Protocol.Target.DetachedFromTargetEvent - connectedTargets.delete(detachParams.sessionId) + connection.connectedTargets.delete(detachParams.sessionId) sendToPlaywright({ message: { method: 'Target.detachedFromTarget', params: detachParams } as CDPEventBase, - source: 'extension' + source: 'extension', + extensionId: connectionId, }) } else if (method === 'Target.targetCrashed') { const crashParams = params as Protocol.Target.TargetCrashedEvent - for (const [sid, target] of connectedTargets.entries()) { + for (const [sid, target] of connection.connectedTargets.entries()) { if (target.targetId === crashParams.targetId) { - connectedTargets.delete(sid) + connection.connectedTargets.delete(sid) logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId) break } @@ -1016,11 +1141,12 @@ export async function startPlayWriterCDPRelayServer({ method: 'Target.targetCrashed', params: crashParams } as CDPEventBase, - source: 'extension' + source: 'extension', + extensionId: connectionId, }) } else if (method === 'Target.targetInfoChanged') { const infoParams = params as Protocol.Target.TargetInfoChangedEvent - for (const target of connectedTargets.values()) { + for (const target of connection.connectedTargets.values()) { if (target.targetId === infoParams.targetInfo.targetId) { target.targetInfo = infoParams.targetInfo break @@ -1032,12 +1158,13 @@ export async function startPlayWriterCDPRelayServer({ method: 'Target.targetInfoChanged', params: infoParams } as CDPEventBase, - source: 'extension' + source: 'extension', + extensionId: connectionId, }) } else if (method === 'Page.frameNavigated') { const frameParams = params as Protocol.Page.FrameNavigatedEvent if (!frameParams.frame.parentId && sessionId) { - const target = connectedTargets.get(sessionId) + const target = connection.connectedTargets.get(sessionId) if (target) { target.targetInfo = { ...target.targetInfo, @@ -1054,12 +1181,13 @@ export async function startPlayWriterCDPRelayServer({ method, params } as CDPEventBase, - source: 'extension' + source: 'extension', + extensionId: connectionId, }) } else if (method === 'Page.navigatedWithinDocument') { const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent if (sessionId) { - const target = connectedTargets.get(sessionId) + const target = connection.connectedTargets.get(sessionId) if (target) { target.targetInfo = { ...target.targetInfo, @@ -1075,7 +1203,8 @@ export async function startPlayWriterCDPRelayServer({ method, params } as CDPEventBase, - source: 'extension' + source: 'extension', + extensionId: connectionId, }) } else { sendToPlaywright({ @@ -1084,35 +1213,36 @@ export async function startPlayWriterCDPRelayServer({ method, params } as CDPEventBase, - source: 'extension' + source: 'extension', + extensionId: connectionId, }) } } }, onClose(event, ws) { - logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'}`) - stopExtensionPing() + logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'} (${connectionId})`) + stopExtensionPing(connectionId) - // If this is an old connection closing after we've already established a new one, - // don't clear the global state - if (extensionWs && extensionWs !== ws) { - logger?.log('Old extension connection closed, keeping new one active') - return + const connection = extensionConnections.get(connectionId) + if (connection) { + for (const pending of connection.pendingRequests.values()) { + pending.reject(new Error('Extension connection closed')) + } + connection.pendingRequests.clear() + connection.connectedTargets.clear() } - for (const pending of extensionPendingRequests.values()) { - pending.reject(new Error('Extension connection closed')) - } - extensionPendingRequests.clear() + extensionConnections.delete(connectionId) + recordingRelays.delete(connectionId) - extensionWs = null - connectedTargets.clear() - - for (const client of playwrightClients.values()) { + for (const [clientId, client] of playwrightClients.entries()) { + if (client.extensionId !== connectionId) { + continue + } client.ws.close(1000, 'Extension disconnected') + playwrightClients.delete(clientId) } - playwrightClients.clear() }, onError(event) { @@ -1153,7 +1283,16 @@ export async function startPlayWriterCDPRelayServer({ } const manager = await getExecutorManager() - const executor = manager.getExecutor(sessionId, cwd) + const defaultExtension = getExtensionConnection(null) + const executor = manager.getExecutor({ + sessionId, + cwd, + sessionMetadata: { + extensionId: defaultExtension?.id || null, + browser: defaultExtension?.info.browser || null, + profile: defaultExtension?.info ? { email: defaultExtension.info.email || '', id: defaultExtension.info.id || '' } : null, + }, + }) const result = await executor.execute(code, timeout) // Increment session counter after each execution to avoid conflicts @@ -1176,7 +1315,16 @@ export async function startPlayWriterCDPRelayServer({ } const manager = await getExecutorManager() - const executor = manager.getExecutor(sessionId, cwd) + const defaultExtension = getExtensionConnection(null) + const executor = manager.getExecutor({ + sessionId, + cwd, + sessionMetadata: { + extensionId: defaultExtension?.id || null, + browser: defaultExtension?.info.browser || null, + profile: defaultExtension?.info ? { email: defaultExtension.info.email || '', id: defaultExtension.info.id || '' } : null, + }, + }) const { page, context } = await executor.reset() return c.json({ @@ -1199,6 +1347,48 @@ export async function startPlayWriterCDPRelayServer({ return c.json({ next: nextSessionNumber }) }) + app.post('/cli/session/new', async (c) => { + const body = await c.req.json().catch(() => ({})) as { extensionId?: string | null } + const sessionId = String(nextSessionNumber++) + const extensionId = body.extensionId || null + const extension = getExtensionConnection(extensionId) + if (!extension) { + return c.json({ error: 'Extension not connected' }, 404) + } + const manager = await getExecutorManager() + const executor = manager.getExecutor({ + sessionId, + sessionMetadata: { + extensionId: extension.id, + browser: extension.info.browser || null, + profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null, + }, + }) + const metadata = executor.getSessionMetadata() + return c.json({ + id: sessionId, + extensionId: metadata.extensionId, + browser: metadata.browser, + profile: metadata.profile, + }) + }) + + app.get('/cli/session/:id', async (c) => { + const sessionId = c.req.param('id') + const manager = await getExecutorManager() + const executor = manager.getSession(sessionId) + if (!executor) { + return c.json({ error: 'not found' }, 404) + } + const metadata = executor.getSessionMetadata() + return c.json({ + id: sessionId, + extensionId: metadata.extensionId, + browser: metadata.browser, + profile: metadata.profile, + }) + }) + app.post('/cli/session/delete', async (c) => { try { const body = await c.req.json() as { sessionId: string } @@ -1214,7 +1404,6 @@ export async function startPlayWriterCDPRelayServer({ if (!deleted) { return c.json({ error: `Session ${sessionId} not found` }, 404) } - return c.json({ success: true }) } catch (error: any) { logger?.error('Delete session endpoint error:', error) @@ -1228,27 +1417,64 @@ export async function startPlayWriterCDPRelayServer({ app.post('/recording/start', async (c) => { const body = await c.req.json() as { outputPath?: string; sessionId?: string; frameRate?: number; audio?: boolean; videoBitsPerSecond?: number; audioBitsPerSecond?: number } - const result = await recordingRelay.startRecording(body as { outputPath: string } & typeof body) + const manager = await getExecutorManager() + const executor = body.sessionId ? manager.getSession(body.sessionId) : null + if (body.sessionId && !executor) { + return c.json({ success: false, error: `Session ${body.sessionId} not found` }, 404) + } + const extensionId = executor?.getSessionMetadata().extensionId || null + const relay = getRecordingRelay(extensionId) + if (!relay) { + return c.json({ success: false, error: 'Extension not connected' }, 500) + } + const result = await relay.startRecording(body as { outputPath: string } & typeof body) const status = result.success ? 200 : (result.error?.includes('required') ? 400 : 500) return c.json(result, status) }) app.post('/recording/stop', async (c) => { const body = await c.req.json() as { sessionId?: string } - const result = await recordingRelay.stopRecording(body) + const manager = await getExecutorManager() + const executor = body.sessionId ? manager.getSession(body.sessionId) : null + if (body.sessionId && !executor) { + return c.json({ success: false, error: `Session ${body.sessionId} not found` }, 404) + } + const extensionId = executor?.getSessionMetadata().extensionId || null + const relay = getRecordingRelay(extensionId) + if (!relay) { + return c.json({ success: false, error: 'Extension not connected' }, 500) + } + const result = await relay.stopRecording(body) const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 500) return c.json(result, status) }) app.get('/recording/status', async (c) => { const sessionId = c.req.query('sessionId') - const result = await recordingRelay.isRecording({ sessionId }) + const manager = await getExecutorManager() + const executor = sessionId ? manager.getSession(sessionId) : null + const extensionId = executor?.getSessionMetadata().extensionId || null + const relay = getRecordingRelay(extensionId) + if (!relay) { + return c.json({ isRecording: false }) + } + const result = await relay.isRecording({ sessionId }) return c.json(result) }) app.post('/recording/cancel', async (c) => { const body = await c.req.json() as { sessionId?: string } - const result = await recordingRelay.cancelRecording(body) + const manager = await getExecutorManager() + const executor = body.sessionId ? manager.getSession(body.sessionId) : null + if (body.sessionId && !executor) { + return c.json({ success: false, error: `Session ${body.sessionId} not found` }, 404) + } + const extensionId = executor?.getSessionMetadata().extensionId || null + const relay = getRecordingRelay(extensionId) + if (!relay) { + return c.json({ success: false, error: 'Extension not connected' }, 500) + } + const result = await relay.cancelRecording(body) return c.json(result) }) @@ -1271,7 +1497,10 @@ export async function startPlayWriterCDPRelayServer({ client.ws.close(1000, 'Server stopped') } playwrightClients.clear() - extensionWs?.close(1000, 'Server stopped') + for (const extension of extensionConnections.values()) { + extension.ws.close(1000, 'Server stopped') + } + extensionConnections.clear() server.close() emitter.removeAllListeners() }, diff --git a/playwriter/src/cli.ts b/playwriter/src/cli.ts index 477846c..e1a1a19 100644 --- a/playwriter/src/cli.ts +++ b/playwriter/src/cli.ts @@ -46,6 +46,54 @@ async function getServerUrl(host?: string): Promise { return `http://${serverHost}:${RELAY_PORT}` } +async function fetchExtensionsStatus(host?: string): Promise> { + try { + const serverUrl = await getServerUrl(host) + const response = await fetch(`${serverUrl}/extensions/status`, { + signal: AbortSignal.timeout(500), + }) + if (!response.ok) { + const fallback = await fetch(`${serverUrl}/extension/status`, { + signal: AbortSignal.timeout(500), + }) + if (!fallback.ok) { + return [] + } + const fallbackData = await fallback.json() as { + connected: boolean + activeTargets: number + browser: string | null + profile: { email: string; id: string } | null + } + if (!fallbackData.connected) { + return [] + } + return [{ + extensionId: 'default', + browser: fallbackData.browser, + profile: fallbackData.profile, + activeTargets: fallbackData.activeTargets, + }] + } + const data = await response.json() as { + extensions: Array<{ + extensionId: string + browser: string | null + profile: { email: string; id: string } | null + activeTargets: number + }> + } + return data.extensions + } catch { + return [] + } +} + async function executeCode(options: { code: string timeout: number @@ -57,19 +105,6 @@ async function executeCode(options: { const cwd = process.cwd() const sessionId = options.sessionId || process.env.PLAYWRITER_SESSION - const serverUrl = await getServerUrl(host) - - // Ensure relay server is running (only for local) - if (!host && !process.env.PLAYWRITER_HOST) { - const restarted = await ensureRelayServer({ logger: console, env: cliRelayEnv }) - if (restarted){ - const connected = await waitForExtension({ logger: console, timeoutMs: 10000 }) - if (!connected) { - console.error('Warning: Extension not connected. Commands may fail.') - } - } - } - // Session is required if (!sessionId) { console.error('Error: -s/--session is required.') @@ -77,6 +112,19 @@ async function executeCode(options: { process.exit(1) } + const serverUrl = await getServerUrl(host) + + // Ensure relay server is running (only for local) + if (!host && !process.env.PLAYWRITER_HOST) { + const restarted = await ensureRelayServer({ logger: console, env: cliRelayEnv }) + if (restarted) { + const connected = await waitForExtension({ logger: console, timeoutMs: 10000 }) + if (!connected) { + console.error('Warning: Extension not connected. Commands may fail.') + } + } + } + // Build request URL with token if provided const executeUrl = `${serverUrl}/cli/execute` @@ -131,17 +179,81 @@ async function executeCode(options: { cli .command('session new', 'Create a new session and print the session ID') .option('--host ', 'Remote relay server host') - .action(async (options: { host?: string }) => { - const serverUrl = await getServerUrl(options.host) - + .option('--browser ', 'Browser to use when multiple browsers are connected') + .action(async (options: { host?: string; browser?: string }) => { if (!options.host && !process.env.PLAYWRITER_HOST) { await ensureRelayServer({ logger: console, env: cliRelayEnv }) } + const extensions = await fetchExtensionsStatus(options.host) + if (extensions.length === 0) { + console.error('No connected browsers detected. Click the Playwriter extension icon.') + process.exit(1) + } + + let selectedExtension: { extensionId: string; browser: string | null; profile: { email: string; id: string } | null } | null = null + + if (extensions.length === 1) { + selectedExtension = extensions[0] + } else if (!options.browser) { + console.log('Multiple browsers detected:\n') + console.log('ID BROWSER PROFILE') + console.log('------- ------- -------') + for (const extension of extensions) { + const label = extension.profile?.email || '(not signed in)' + const shortId = extension.extensionId === 'default' ? 'default' : extension.extensionId.slice(0, 7) + console.log(`${shortId.padEnd(7)} ${(extension.browser || 'Chrome').padEnd(7)} ${label}`) + } + console.log('\nRun again with --browser .') + process.exit(1) + } else { + const byEmail = extensions.find((extension) => extension.profile?.email === options.browser) + const byId = extensions.find((extension) => extension.extensionId === options.browser) + const byBrowser = extensions.filter((extension) => (extension.browser || 'Chrome') === options.browser) + + selectedExtension = byEmail || byId || null + if (!selectedExtension && byBrowser.length === 1) { + selectedExtension = byBrowser[0] + } + + if (!selectedExtension) { + if (byBrowser.length > 1) { + console.error(`Browser name is ambiguous: ${options.browser}`) + process.exit(1) + } + console.error(`Browser not found: ${options.browser}`) + process.exit(1) + } + } + + if (!selectedExtension) { + console.error('Unable to determine browser identity.') + process.exit(1) + } + + if (!options.host && !process.env.PLAYWRITER_HOST) { + await ensureRelayServer({ logger: console, env: cliRelayEnv }) + const connected = await waitForExtension({ logger: console, timeoutMs: 10000 }) + if (!connected) { + console.error('Warning: Extension not connected. Commands may fail.') + } + } + try { - const res = await fetch(`${serverUrl}/cli/session/suggest`) - const { next } = await res.json() as { next: number } - console.log(`Session ${next} created. Use with: playwriter -s ${next} -e "..."`) + const serverUrl = await getServerUrl(options.host) + const extensionId = selectedExtension.extensionId === 'default' ? null : selectedExtension.extensionId + const response = await fetch(`${serverUrl}/cli/session/new`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ extensionId }), + }) + if (!response.ok) { + const text = await response.text() + console.error(`Error: ${response.status} ${text}`) + process.exit(1) + } + const result = await response.json() as { id: string; extensionId: string | null } + console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`) } catch (error: any) { console.error(`Error: ${error.message}`) process.exit(1) @@ -152,43 +264,81 @@ cli .command('session list', 'List all active sessions') .option('--host ', 'Remote relay server host') .action(async (options: { host?: string }) => { - const serverUrl = await getServerUrl(options.host) - if (!options.host && !process.env.PLAYWRITER_HOST) { await ensureRelayServer({ logger: console, env: cliRelayEnv }) } + const serverUrl = await getServerUrl(options.host) + let sessions: Array<{ + id: string + stateKeys: string[] + browser: string | null + profile: { email: string; id: string } | null + extensionId: string | null + }> = [] + try { - const res = await fetch(`${serverUrl}/cli/sessions`) - const { sessions } = await res.json() as { + const response = await fetch(`${serverUrl}/cli/sessions`, { + signal: AbortSignal.timeout(500), + }) + if (!response.ok) { + console.error(`Error: ${response.status} ${await response.text()}`) + process.exit(1) + } + const result = await response.json() as { sessions: Array<{ id: string stateKeys: string[] + browser: string | null + profile: { email: string; id: string } | null + extensionId: string | null }> } - - if (sessions.length === 0) { - console.log('No active sessions') - return - } - - // Calculate column widths for aligned table - const idWidth = Math.max(2, ...sessions.map((s) => { return String(s.id).length })) - const stateWidth = Math.max(10, ...sessions.map((s) => { return s.stateKeys.join(', ').length || 1 })) - - // Header - console.log('ID'.padEnd(idWidth) + ' ' + 'State Keys') - console.log('-'.repeat(idWidth + stateWidth + 2)) - - // Rows - for (const session of sessions) { - const stateStr = session.stateKeys.length > 0 ? session.stateKeys.join(', ') : '-' - console.log(String(session.id).padEnd(idWidth) + ' ' + stateStr) - } + sessions = result.sessions } catch (error: any) { console.error(`Error: ${error.message}`) process.exit(1) } + + if (sessions.length === 0) { + console.log('No active sessions') + return + } + + const idWidth = Math.max(2, ...sessions.map((session) => String(session.id).length)) + const browserWidth = Math.max(7, ...sessions.map((session) => (session.browser || 'Chrome').length)) + const profileWidth = Math.max(7, ...sessions.map((session) => (session.profile?.email || '').length || 1)) + const extensionWidth = Math.max(2, ...sessions.map((session) => (session.extensionId || '').length || 1)) + const stateWidth = Math.max(10, ...sessions.map((session) => session.stateKeys.join(', ').length || 1)) + + console.log( + 'ID'.padEnd(idWidth) + + ' ' + + 'BROWSER'.padEnd(browserWidth) + + ' ' + + 'PROFILE'.padEnd(profileWidth) + + ' ' + + 'EXT'.padEnd(extensionWidth) + + ' ' + + 'STATE KEYS' + ) + console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + stateWidth + 8)) + + for (const session of sessions) { + const stateStr = session.stateKeys.length > 0 ? session.stateKeys.join(', ') : '-' + const profileLabel = session.profile?.email || '-' + console.log( + String(session.id).padEnd(idWidth) + + ' ' + + (session.browser || 'Chrome').padEnd(browserWidth) + + ' ' + + profileLabel.padEnd(profileWidth) + + ' ' + + (session.extensionId || '-').padEnd(extensionWidth) + + ' ' + + stateStr + ) + } }) cli diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index 3e55c22..16d59de 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -181,10 +181,18 @@ export interface CdpConfig { host?: string port?: number token?: string + extensionId?: string | null +} + +export interface SessionMetadata { + extensionId: string | null + browser: string | null + profile: { email: string; id: string } | null } export interface ExecutorOptions { cdpConfig: CdpConfig + sessionMetadata?: SessionMetadata logger?: ExecutorLogger /** Working directory for scoped fs access */ cwd?: string @@ -216,10 +224,12 @@ export class PlaywrightExecutor { private cdpConfig: CdpConfig private logger: ExecutorLogger + private sessionMetadata: SessionMetadata constructor(options: ExecutorOptions) { this.cdpConfig = options.cdpConfig this.logger = options.logger || { log: console.log, error: console.error } + this.sessionMetadata = options.sessionMetadata || { extensionId: null, browser: null, profile: null } // ScopedFS expects an array of allowed directories. If cwd is provided, use it; otherwise use defaults. this.scopedFs = new ScopedFS(options.cwd ? [options.cwd, '/tmp', os.tmpdir()] : undefined) this.sandboxedRequire = this.createSandboxedRequire(require) @@ -320,8 +330,31 @@ export class PlaywrightExecutor { } private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number }> { - const { host = '127.0.0.1', port = 19988 } = this.cdpConfig + const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig try { + if (extensionId) { + const response = await fetch(`http://${host}:${port}/extensions/status`, { + signal: AbortSignal.timeout(2000), + }) + if (!response.ok) { + const fallback = await fetch(`http://${host}:${port}/extension/status`, { + signal: AbortSignal.timeout(2000), + }) + if (!fallback.ok) { + return { connected: false, activeTargets: 0 } + } + return (await fallback.json()) as { connected: boolean; activeTargets: number } + } + const data = await response.json() as { + extensions: Array<{ extensionId: string; activeTargets: number }> + } + const extension = data.extensions.find((item) => item.extensionId === extensionId) + if (!extension) { + return { connected: false, activeTargets: 0 } + } + return { connected: true, activeTargets: extension.activeTargets } + } + const response = await fetch(`http://${host}:${port}/extension/status`, { signal: AbortSignal.timeout(2000), }) @@ -910,6 +943,10 @@ export class PlaywrightExecutor { getStateKeys(): string[] { return Object.keys(this.userState) } + + getSessionMetadata(): SessionMetadata { + return this.sessionMetadata + } } /** @@ -917,19 +954,25 @@ export class PlaywrightExecutor { */ export class ExecutorManager { private executors = new Map() - private cdpConfig: CdpConfig + private cdpConfig: CdpConfig | ((sessionId: string) => CdpConfig) private logger: ExecutorLogger - constructor(options: { cdpConfig: CdpConfig; logger?: ExecutorLogger }) { + constructor(options: { cdpConfig: CdpConfig | ((sessionId: string) => CdpConfig); logger?: ExecutorLogger }) { this.cdpConfig = options.cdpConfig this.logger = options.logger || { log: console.log, error: console.error } } - getExecutor(sessionId: string, cwd?: string): PlaywrightExecutor { + getExecutor(options: { sessionId: string; cwd?: string; sessionMetadata?: SessionMetadata }): PlaywrightExecutor { + const { sessionId, cwd, sessionMetadata } = options let executor = this.executors.get(sessionId) if (!executor) { + const baseConfig = typeof this.cdpConfig === 'function' ? this.cdpConfig(sessionId) : this.cdpConfig + const cdpConfig = sessionMetadata?.extensionId + ? { ...baseConfig, extensionId: sessionMetadata.extensionId } + : baseConfig executor = new PlaywrightExecutor({ - cdpConfig: this.cdpConfig, + cdpConfig, + sessionMetadata, logger: this.logger, cwd, }) @@ -942,11 +985,25 @@ export class ExecutorManager { return this.executors.delete(sessionId) } - listSessions(): Array<{ id: string; stateKeys: string[] }> { + getSession(sessionId: string): PlaywrightExecutor | null { + return this.executors.get(sessionId) || null + } + + listSessions(): Array<{ + id: string + stateKeys: string[] + extensionId: string | null + browser: string | null + profile: { email: string; id: string } | null + }> { return [...this.executors.entries()].map(([id, executor]) => { + const metadata = executor.getSessionMetadata() return { id, stateKeys: executor.getStateKeys(), + extensionId: metadata.extensionId, + browser: metadata.browser, + profile: metadata.profile, } }) } diff --git a/playwriter/src/utils.ts b/playwriter/src/utils.ts index de09330..a3d697f 100644 --- a/playwriter/src/utils.ts +++ b/playwriter/src/utils.ts @@ -9,10 +9,28 @@ export const EXTENSION_IDS = [ 'pebbngnfojnignonigcnkdilknapkgid', // Dev extension (stable ID from manifest key) ] -export function getCdpUrl({ port = 19988, host = '127.0.0.1', token }: { port?: number; host?: string; token?: string } = {}) { +export function getCdpUrl({ + port = 19988, + host = '127.0.0.1', + token, + extensionId, +}: { + port?: number + host?: string + token?: string + extensionId?: string | null +} = {}) { const id = `${Math.random().toString(36).substring(2, 15)}_${Date.now()}` - const queryString = token ? `?token=${token}` : '' - return `ws://${host}:${port}/cdp/${id}${queryString}` + const params = new URLSearchParams() + if (token) { + params.set('token', token) + } + if (extensionId) { + params.set('extensionId', extensionId) + } + const queryString = params.toString() + const suffix = queryString ? `?${queryString}` : '' + return `ws://${host}:${port}/cdp/${id}${suffix}` } const LOG_BASE_DIR = os.platform() === 'win32' ? os.tmpdir() : '/tmp'