From 0df52a08c0b6fe16810f144cf87305c25769f7b0 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 25 Nov 2025 10:35:39 +0100 Subject: [PATCH] removed relayConnection.ts. added diff for ai snapshot. use search param --- AGENTS.md | 2 +- PLAYWRITER_AGENTS.md | 2 +- extension/src/background.ts | 1009 ++++++++++++++++++------------ extension/src/relayConnection.ts | 494 --------------- extension/src/types.ts | 8 +- playwriter/package.json | 1 + playwriter/src/mcp.test.ts | 14 +- playwriter/src/mcp.ts | 25 +- playwriter/src/prompt.md | 3 +- pnpm-lock.yaml | 9 + 10 files changed, 664 insertions(+), 903 deletions(-) delete mode 100644 extension/src/relayConnection.ts diff --git a/AGENTS.md b/AGENTS.md index 7873057..9705ad9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ remember that every time the extension is activated in a tab that tab gets added to debug server or extension issues you can also inspect the file @playwriter/relay-server.log to see both extension and server logs. with all cdp events sent. to see if there are events missing or something broken. this file is recreated every time the server is started and appended in real time. use rg to only read relevant lines and parts because it can get quite long -tests will take about 30 seconds, so set a timeout of at least 60 seconds when running the test bash command +IMPORTANT: `pnpm test` will take about 30 seconds so set a timeout of at least 3600ms when running the pnpm test bash command ## changelogs diff --git a/PLAYWRITER_AGENTS.md b/PLAYWRITER_AGENTS.md index e1c3dd7..dcebae1 100644 --- a/PLAYWRITER_AGENTS.md +++ b/PLAYWRITER_AGENTS.md @@ -55,7 +55,7 @@ remember that every time the extension is activated in a tab that tab gets added to debug server or extension issues you can also inspect the file @playwriter/relay-server.log to see both extension and server logs. with all cdp events sent. to see if there are events missing or something broken. this file is recreated every time the server is started and appended in real time. use rg to only read relevant lines and parts because it can get quite long -tests will take about 30 seconds, so set a timeout of at least 60 seconds when running the test bash command +IMPORTANT: `pnpm test` will take about 30 seconds so set a timeout of at least 3600ms when running the pnpm test bash command ## changelogs diff --git a/extension/src/background.ts b/extension/src/background.ts index 1a873cd..5e3dfbc 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,13 +1,16 @@ -import { RelayConnection, logger } from './relayConnection' import { createStore } from 'zustand/vanilla' import type { ExtensionState, ConnectionState, TabState, TabInfo } from './types' +import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types' +import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/extension/protocol' -// Relay URL - fixed port for MCP bridge const RELAY_URL = 'ws://localhost:19988/extension' -const useExtensionStore = createStore((set) => ({ - connection: undefined, - connectedTabs: new Map(), +let ws: WebSocket | null = null +let childSessions: Map = new Map() +let nextSessionId = 1 + +const store = createStore(() => ({ + tabs: new Map(), connectionState: 'disconnected', currentTabId: undefined, errorText: undefined, @@ -15,6 +18,563 @@ const useExtensionStore = createStore((set) => ({ // @ts-ignore globalThis.toggleExtensionForActiveTab = toggleExtensionForActiveTab +// @ts-ignore +globalThis.disconnectEverything = disconnectEverything +// @ts-ignore +globalThis.getExtensionState = () => store.getState() + +declare global { + var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }> + var getExtensionState: () => ExtensionState + var disconnectEverything: () => Promise +} + +function safeSerialize(arg: any): string { + if (arg === undefined) return 'undefined' + if (arg === null) return 'null' + if (typeof arg === 'function') return `[Function: ${arg.name || 'anonymous'}]` + if (typeof arg === 'symbol') return String(arg) + if (arg instanceof Error) return arg.stack || arg.message || String(arg) + if (typeof arg === 'object') { + try { + const seen = new WeakSet() + return JSON.stringify(arg, (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) return '[Circular]' + seen.add(value) + if (value instanceof Map) return { dataType: 'Map', value: Array.from(value.entries()) } + if (value instanceof Set) return { dataType: 'Set', value: Array.from(value.values()) } + } + return value + }) + } catch { + return String(arg) + } + } + return String(arg) +} + +function sendLog(level: string, args: any[]) { + sendMessage({ + method: 'log', + params: { level, args: args.map(safeSerialize) }, + }) +} + +const logger = { + log: (...args: any[]) => { + console.log(...args) + sendLog('log', args) + }, + debug: (...args: any[]) => { + console.debug(...args) + sendLog('debug', args) + }, + info: (...args: any[]) => { + console.info(...args) + sendLog('info', args) + }, + warn: (...args: any[]) => { + console.warn(...args) + sendLog('warn', args) + }, + error: (...args: any[]) => { + console.error(...args) + sendLog('error', args) + }, +} + +function sendMessage(message: any): void { + if (ws?.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify(message)) + } catch (error: any) { + console.debug('ERROR sending message:', error, 'message type:', message.method || 'response') + } + } +} + +function getTabBySessionId(sessionId: string): { tabId: number; tab: TabInfo } | undefined { + for (const [tabId, tab] of store.getState().tabs) { + if (tab.sessionId === sessionId) { + return { tabId, tab } + } + } + return undefined +} + +function getTabByTargetId(targetId: string): { tabId: number; tab: TabInfo } | undefined { + for (const [tabId, tab] of store.getState().tabs) { + if (tab.targetId === targetId) { + return { tabId, tab } + } + } + return undefined +} + +async function handleCommand(msg: ExtensionCommandMessage): Promise { + if (msg.method !== 'forwardCDPCommand') return + + let targetTabId: number | undefined + let targetTab: TabInfo | undefined + + if (msg.params.sessionId) { + const found = getTabBySessionId(msg.params.sessionId) + if (found) { + targetTabId = found.tabId + targetTab = found.tab + } + } + + if (!targetTab && msg.params.sessionId) { + const parentTabId = childSessions.get(msg.params.sessionId) + if (parentTabId) { + targetTabId = parentTabId + targetTab = store.getState().tabs.get(parentTabId) + logger.debug('Found parent tab for child session:', msg.params.sessionId, 'tabId:', parentTabId) + } + } + + if (!targetTab && msg.params.params && 'targetId' in msg.params.params && msg.params.params.targetId) { + const found = getTabByTargetId(msg.params.params.targetId as string) + if (found) { + targetTabId = found.tabId + targetTab = found.tab + logger.debug('Found tab for targetId:', msg.params.params.targetId, 'tabId:', targetTabId) + } + } + + const debuggee = targetTabId ? { tabId: targetTabId } : undefined + + switch (msg.params.method) { + case 'Runtime.enable': { + if (!debuggee) { + throw new Error(`No debuggee found for Runtime.enable (sessionId: ${msg.params.sessionId})`) + } + try { + await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') + await new Promise((resolve) => setTimeout(resolve, 200)) + } catch (e) { + logger.debug('Error disabling Runtime (ignoring):', e) + } + return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', msg.params.params) + } + + case 'Target.createTarget': { + const url = msg.params.params?.url || 'about:blank' + logger.debug('Creating new tab with URL:', url) + const tab = await chrome.tabs.create({ url, active: false }) + if (!tab.id) throw new Error('Failed to create tab') + logger.debug('Created tab:', tab.id, 'waiting for it to load...') + await new Promise((resolve) => setTimeout(resolve, 100)) + const targetInfo = await attachTab(tab.id) + return { targetId: targetInfo.targetId } satisfies Protocol.Target.CreateTargetResponse + } + + case 'Target.closeTarget': { + if (!targetTabId) { + logger.log(`Target not found: ${msg.params.params?.targetId}`) + return { success: false } satisfies Protocol.Target.CloseTargetResponse + } + await chrome.tabs.remove(targetTabId) + return { success: true } satisfies Protocol.Target.CloseTargetResponse + } + } + + if (!debuggee || !targetTab) { + throw new Error( + `No tab found for method ${msg.params.method} sessionId: ${msg.params.sessionId} params: ${JSON.stringify(msg.params.params || null)}`, + ) + } + + logger.debug('CDP command:', msg.params.method, 'for tab:', targetTabId) + + const debuggerSession: chrome.debugger.DebuggerSession = { + ...debuggee, + sessionId: msg.params.sessionId !== targetTab.sessionId ? msg.params.sessionId : undefined, + } + + return await chrome.debugger.sendCommand(debuggerSession, msg.params.method, msg.params.params) +} + +function onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void { + const tab = source.tabId ? store.getState().tabs.get(source.tabId) : undefined + if (!tab) return + + logger.debug('Forwarding CDP event:', method, 'from tab:', source.tabId) + + if (method === 'Target.attachedToTarget' && params?.sessionId) { + logger.debug('Child target attached:', params.sessionId, 'for tab:', source.tabId) + childSessions.set(params.sessionId, source.tabId!) + } + + if (method === 'Target.detachedFromTarget' && params?.sessionId) { + logger.debug('Child target detached:', params.sessionId) + childSessions.delete(params.sessionId) + } + + sendMessage({ + method: 'forwardCDPEvent', + params: { + sessionId: source.sessionId || tab.sessionId, + method, + params, + }, + }) +} + +function onDebuggerDetach(source: chrome.debugger.Debuggee, reason: `${chrome.debugger.DetachReason}`): void { + const tabId = source.tabId + if (!tabId || !store.getState().tabs.has(tabId)) { + logger.debug('Ignoring debugger detach event for untracked tab:', tabId) + return + } + + logger.debug(`Manual debugger detachment detected for tab ${tabId}: ${reason}`) + + const tab = store.getState().tabs.get(tabId) + if (tab) { + sendMessage({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: tab.sessionId, targetId: tab.targetId }, + }, + }) + } + + for (const [childSessionId, parentTabId] of childSessions.entries()) { + if (parentTabId === tabId) { + logger.debug('Cleaning up child session:', childSessionId, 'for tab:', tabId) + childSessions.delete(childSessionId) + } + } + + store.setState((state) => { + const newTabs = new Map(state.tabs) + newTabs.delete(tabId) + return { tabs: newTabs } + }) + + if (reason === chrome.debugger.DetachReason.CANCELED_BY_USER) { + store.setState({ connectionState: 'disconnected' }) + } +} + +async function attachTab(tabId: number): Promise { + const debuggee = { tabId } + + logger.debug('Attaching debugger to tab:', tabId) + await chrome.debugger.attach(debuggee, '1.3') + logger.debug('Debugger attached successfully to tab:', tabId) + + const result = (await chrome.debugger.sendCommand( + debuggee, + 'Target.getTargetInfo', + )) as Protocol.Target.GetTargetInfoResponse + + const targetInfo = result.targetInfo + const sessionId = `pw-tab-${nextSessionId++}` + + store.setState((state) => { + const newTabs = new Map(state.tabs) + newTabs.set(tabId, { + sessionId, + targetId: targetInfo.targetId, + state: 'connected', + }) + return { tabs: newTabs, connectionState: 'connected' } + }) + + sendMessage({ + method: 'forwardCDPEvent', + params: { + method: 'Target.attachedToTarget', + params: { + sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, + }, + }) + + logger.debug('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId) + return targetInfo +} + +function detachTab(tabId: number, shouldDetachDebugger: boolean): void { + const tab = store.getState().tabs.get(tabId) + if (!tab) { + logger.debug('detachTab: tab not found in map:', tabId) + return + } + + logger.debug('Detaching tab:', tabId, 'sessionId:', tab.sessionId, 'shouldDetach:', shouldDetachDebugger) + + sendMessage({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: tab.sessionId, targetId: tab.targetId }, + }, + }) + + store.setState((state) => { + const newTabs = new Map(state.tabs) + newTabs.delete(tabId) + return { tabs: newTabs } + }) + + for (const [childSessionId, parentTabId] of childSessions.entries()) { + if (parentTabId === tabId) { + logger.debug('Cleaning up child session:', childSessionId, 'for tab:', tabId) + childSessions.delete(childSessionId) + } + } + + if (shouldDetachDebugger) { + chrome.debugger.detach({ tabId }).catch((err) => { + logger.debug('Error detaching debugger from tab:', tabId, err.message) + }) + } +} + +function closeConnection(reason: string): void { + logger.debug('Closing connection, reason:', reason) + + chrome.debugger.onEvent.removeListener(onDebuggerEvent) + chrome.debugger.onDetach.removeListener(onDebuggerDetach) + + for (const [tabId] of store.getState().tabs) { + chrome.debugger.detach({ tabId }).catch((err) => { + logger.debug('Error detaching from tab:', tabId, err.message) + }) + } + + store.setState({ tabs: new Map() }) + childSessions.clear() + + if (ws) { + ws.close(1000, reason) + ws = null + } +} + +function handleConnectionClose(reason: string, code: number): void { + logger.debug('Connection closed:', { reason, code }) + + chrome.debugger.onEvent.removeListener(onDebuggerEvent) + chrome.debugger.onDetach.removeListener(onDebuggerDetach) + + const { tabs } = store.getState() + + for (const [tabId] of tabs) { + chrome.debugger.detach({ tabId }).catch((err) => { + logger.debug('Error detaching from tab:', tabId, err.message) + }) + } + + childSessions.clear() + ws = null + + if (reason === 'Extension Replaced' || code === 4001) { + logger.debug('Connection replaced by another extension instance') + store.setState({ + connectionState: 'error', + errorText: 'Disconnected: Replaced by another extension', + }) + return + } + + store.setState({ connectionState: 'disconnected' }) + + if (tabs.size > 0) { + logger.debug('Tabs still connected, triggering reconnection') + void reconnect() + } +} + +async function ensureConnection(): Promise { + if (ws?.readyState === WebSocket.OPEN) { + logger.debug('Connection already exists, reusing') + return + } + + logger.debug('Waiting for server at http://localhost:19988...') + store.setState({ connectionState: 'reconnecting' }) + + while (true) { + try { + await fetch('http://localhost:19988', { method: 'HEAD' }) + logger.debug('Server is available') + break + } catch { + logger.debug('Server not available, retrying in 1 second...') + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + logger.debug('Creating WebSocket connection to:', RELAY_URL) + const socket = new WebSocket(RELAY_URL) + + await new Promise((resolve, reject) => { + let timeoutFired = false + const timeout = setTimeout(() => { + timeoutFired = true + logger.debug('WebSocket connection TIMEOUT after 5 seconds') + reject(new Error('Connection timeout')) + }, 5000) + + socket.onopen = () => { + if (timeoutFired) return + logger.debug('WebSocket connected') + clearTimeout(timeout) + resolve() + } + + socket.onerror = (error) => { + logger.debug('WebSocket error during connection:', error) + if (!timeoutFired) { + clearTimeout(timeout) + reject(new Error('WebSocket connection failed')) + } + } + + socket.onclose = (event) => { + logger.debug('WebSocket closed during connection:', { code: event.code, reason: event.reason }) + if (!timeoutFired) { + clearTimeout(timeout) + reject(new Error(`WebSocket closed: ${event.reason || event.code}`)) + } + } + }) + + ws = socket + + ws.onmessage = async (event: MessageEvent) => { + let message: ExtensionCommandMessage + try { + message = JSON.parse(event.data) + } catch (error: any) { + logger.debug('Error parsing message:', error) + sendMessage({ error: { code: -32700, message: `Error parsing message: ${error.message}` } }) + return + } + + const response: ExtensionResponseMessage = { id: message.id } + try { + response.result = await handleCommand(message) + } catch (error: any) { + logger.debug('Error handling command:', error) + response.error = error.message + } + logger.debug('Sending response:', response) + sendMessage(response) + } + + ws.onclose = (event: CloseEvent) => { + handleConnectionClose(event.reason, event.code) + } + + ws.onerror = (event: Event) => { + logger.debug('WebSocket error:', event) + } + + chrome.debugger.onEvent.addListener(onDebuggerEvent) + chrome.debugger.onDetach.addListener(onDebuggerDetach) + + logger.debug('Connection established') +} + +async function connectTab(tabId: number): Promise { + try { + logger.debug(`Starting connection to tab ${tabId}`) + + store.setState((state) => { + const newTabs = new Map(state.tabs) + newTabs.set(tabId, { state: 'connecting' }) + return { tabs: newTabs } + }) + + await ensureConnection() + await attachTab(tabId) + + logger.debug(`Successfully connected to tab ${tabId}`) + } catch (error: any) { + logger.debug(`Failed to connect to tab ${tabId}:`, error) + + store.setState((state) => { + const newTabs = new Map(state.tabs) + newTabs.set(tabId, { state: 'error', errorText: `Error: ${error.message}` }) + const nextConnectionState = state.connectionState === 'reconnecting' ? 'disconnected' : state.connectionState + return { tabs: newTabs, connectionState: nextConnectionState } + }) + } +} + +async function disconnectTab(tabId: number): Promise { + logger.debug(`Disconnecting tab ${tabId}`) + + const { tabs } = store.getState() + if (!tabs.has(tabId)) { + logger.debug('Tab not in tabs map, ignoring disconnect') + return + } + + detachTab(tabId, true) + + const { tabs: updatedTabs } = store.getState() + if (updatedTabs.size === 0 && ws) { + logger.debug('No tabs remaining, closing connection') + closeConnection('All tabs disconnected') + store.setState({ connectionState: 'disconnected' }) + } +} + +async function reconnect(): Promise { + logger.debug('Starting reconnection') + const { tabs } = store.getState() + const tabsToReconnect = Array.from(tabs.keys()) + logger.debug('Tabs to reconnect:', tabsToReconnect) + + try { + await ensureConnection() + + for (const tabId of tabsToReconnect) { + if (!store.getState().tabs.has(tabId)) { + logger.debug('Tab', tabId, 'was manually disconnected during reconnection, skipping') + continue + } + + try { + await chrome.tabs.get(tabId) + await attachTab(tabId) + logger.debug('Successfully re-attached tab:', tabId) + } catch (error: any) { + logger.debug('Failed to re-attach tab:', tabId, error.message) + store.setState((state) => { + const newTabs = new Map(state.tabs) + newTabs.delete(tabId) + return { tabs: newTabs } + }) + } + } + + const { tabs: finalTabs } = store.getState() + if (finalTabs.size > 0) { + store.setState({ connectionState: 'connected' }) + } else { + store.setState({ connectionState: 'disconnected' }) + } + } catch (error: any) { + logger.debug('Reconnection failed:', error) + store.setState({ + tabs: new Map(), + connectionState: 'error', + errorText: 'Reconnection failed - Click to retry', + }) + } +} async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; state: ExtensionState }> { const tabs = await chrome.tabs.query({ active: true, currentWindow: true }) @@ -23,88 +583,49 @@ async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; st await onActionClicked(tab) - // Wait for state to settle await new Promise((resolve) => { const check = () => { - const state = useExtensionStore.getState() - const tabInfo = state.connectedTabs.get(tab.id!) - - // If we are connecting, wait - if (tabInfo?.state === 'connecting') { + const state = store.getState() + const tabInfo = state.tabs.get(tab.id!) + if (tabInfo?.state === 'connecting' || state.connectionState === 'reconnecting') { setTimeout(check, 100) return } - - // Also wait if global connection is reconnecting - if (state.connectionState === 'reconnecting') { - setTimeout(check, 100) - return - } - resolve() } check() }) - const state = useExtensionStore.getState() - const isConnected = state.connectedTabs.has(tab.id) && state.connectedTabs.get(tab.id)?.state === 'connected' - + const state = store.getState() + const isConnected = state.tabs.has(tab.id) && state.tabs.get(tab.id)?.state === 'connected' return { isConnected, state } } -// @ts-ignore -globalThis.disconnectEverything = disconnectEverything +async function disconnectEverything(): Promise { + const { tabs } = store.getState() -async function disconnectEverything() { - const { connectedTabs, connection } = useExtensionStore.getState() - - // Disconnect all tabs - for (const tabId of connectedTabs.keys()) { + for (const tabId of tabs.keys()) { await disconnectTab(tabId) } - // Force close connection if it still exists - const state = useExtensionStore.getState() - if (state.connection) { - state.connection.close('Manual full disconnect') - useExtensionStore.setState({ - connection: undefined, + if (ws) { + closeConnection('Manual full disconnect') + store.setState({ connectionState: 'disconnected', - connectedTabs: new Map(), + tabs: new Map(), errorText: undefined, }) } } -// @ts-ignore -globalThis.getExtensionState = () => useExtensionStore.getState() - -declare global { - var state: typeof useExtensionStore - var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }> - var getExtensionState: () => ExtensionState - var disconnectEverything: () => Promise -} - -async function resetDebugger() { +async function resetDebugger(): Promise { let targets = await chrome.debugger.getTargets() targets = targets.filter((x) => x.tabId && x.attached) logger.log(`found ${targets.length} existing debugger targets. detaching them before background script starts`) - logger.log(targets) for (const target of targets) { await chrome.debugger.detach({ tabId: target.tabId }) } } -resetDebugger() - -chrome.runtime.onInstalled.addListener((details) => { - if (import.meta.env.TESTING) { - return - } - if (details.reason === 'install') { - void chrome.tabs.create({ url: 'welcome.html' }) - } -}) const icons = { connected: { @@ -153,42 +674,28 @@ const icons = { }, } as const -async function updateIcons() { - const state = useExtensionStore.getState() - const { connectionState, connectedTabs, errorText } = state +async function updateIcons(): Promise { + const state = store.getState() + const { connectionState, tabs, errorText } = state - const tabs = await chrome.tabs.query({}) - const allTabIds = [undefined, ...tabs.map((tab) => tab.id).filter((id): id is number => id !== undefined)] + const allTabs = await chrome.tabs.query({}) + const allTabIds = [undefined, ...allTabs.map((tab) => tab.id).filter((id): id is number => id !== undefined)] for (const tabId of allTabIds) { - const tabInfo = tabId !== undefined ? connectedTabs.get(tabId) : undefined + const tabInfo = tabId !== undefined ? tabs.get(tabId) : undefined const iconConfig = (() => { - if (connectionState === 'error') { - return icons.error - } - if (connectionState === 'reconnecting') { - return icons.connecting - } - if (tabInfo?.state === 'error') { - return icons.error - } - if (tabInfo?.state === 'connecting') { - return icons.connecting - } - if (tabInfo?.state === 'connected') { - return icons.connected - } + if (connectionState === 'error') return icons.error + if (connectionState === 'reconnecting') return icons.connecting + if (tabInfo?.state === 'error') return icons.error + if (tabInfo?.state === 'connecting') return icons.connecting + if (tabInfo?.state === 'connected') return icons.connected return icons.disconnected })() const title = (() => { - if (connectionState === 'error' && errorText) { - return errorText - } - if (tabInfo?.errorText) { - return tabInfo.errorText - } + if (connectionState === 'error' && errorText) return errorText + if (tabInfo?.errorText) return tabInfo.errorText return iconConfig.title })() @@ -199,308 +706,15 @@ async function updateIcons() { } } -useExtensionStore.subscribe(async (state, prevState) => { - logger.log(state) - await updateIcons() -}) - -async function ensureConnection(): Promise { - const { connection } = useExtensionStore.getState() - if (connection) { - logger.debug('Connection already exists, reusing') - return - } - - logger.debug('No existing connection, creating new relay connection') - logger.debug('Waiting for server at http://localhost:19988...') - - useExtensionStore.setState({ connectionState: 'reconnecting' }) - while (true) { - try { - await fetch('http://localhost:19988', { method: 'HEAD' }) - logger.debug('Server is available') - break - } catch (error: any) { - logger.debug('Server not available, retrying in 1 second...') - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - } - - logger.debug('Server is ready, creating WebSocket connection to:', RELAY_URL) - const socket = new WebSocket(RELAY_URL) - logger.debug( - 'WebSocket created, initial readyState:', - socket.readyState, - '(0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)', - ) - - await new Promise((resolve, reject) => { - let timeoutFired = false - const timeout = setTimeout(() => { - timeoutFired = true - logger.debug('=== WebSocket connection TIMEOUT after 5 seconds ===') - logger.debug('Final WebSocket readyState:', socket.readyState) - logger.debug('WebSocket URL:', socket.url) - logger.debug('Socket protocol:', socket.protocol) - reject(new Error('Connection timeout')) - }, 5000) - - socket.onopen = () => { - if (timeoutFired) { - logger.debug('WebSocket opened but timeout already fired!') - return - } - logger.debug('WebSocket onopen fired! readyState:', socket.readyState) - clearTimeout(timeout) - resolve() - } - - socket.onerror = (error) => { - logger.debug('WebSocket onerror during connection:', error) - logger.debug('Error type:', error.type) - logger.debug('Current readyState:', socket.readyState) - if (!timeoutFired) { - clearTimeout(timeout) - reject(new Error('WebSocket connection failed')) - } - } - - socket.onclose = (event) => { - logger.debug('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}`)) - } - } - - logger.debug('Event handlers set, waiting for connection...') - }) - - logger.debug('WebSocket connected successfully, creating RelayConnection instance') - const newConnection = new RelayConnection({ - ws: socket, - onClose: (reason, code) => { - logger.debug('=== Relay connection onClose callback triggered ===', { reason, code }) - const { connectedTabs } = useExtensionStore.getState() - logger.debug('Connected tabs before potential reconnection:', Array.from(connectedTabs.keys())) - - if (reason === 'Extension Replaced' || code === 4001) { - logger.debug('Connection replaced by another extension instance. Not reconnecting.') - useExtensionStore.setState({ - connection: undefined, - connectionState: 'error', - errorText: 'Disconnected: Replaced by another extension', - }) - return - } - - useExtensionStore.setState({ connection: undefined, connectionState: 'disconnected' }) - - if (connectedTabs.size > 0) { - logger.debug('Tabs still connected, triggering reconnection') - void reconnect() - } else { - logger.debug('No tabs to reconnect') - } - }, - onTabDetached: (tabId, reason) => { - logger.debug('=== Manual tab detachment detected for tab:', tabId, '===') - logger.debug('User closed debugger via Chrome automation bar') - - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs) - newTabs.delete(tabId) - return { connectedTabs: newTabs } - }) - if (reason === chrome.debugger.DetachReason.CANCELED_BY_USER) { - // if user cancels debugger. disconnect everything - useExtensionStore.setState({ connectionState: 'disconnected' }) - } - logger.debug('Removed tab from _connectedTabs map') - }, - onTabAttached: (tabId, targetId) => { - // logger.debug('=== Tab attached callback for tab:', tabId, '===') - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs) - newTabs.set(tabId, { - targetId, - state: 'connected', - }) - return { connectedTabs: newTabs, connectionState: 'connected' } - }) - }, - }) - - useExtensionStore.setState({ connection: newConnection }) - logger.debug('Connection established, WebSocket open (caller should set connectionState)') -} - -async function connectTab(tabId: number): Promise { - try { - logger.debug(`=== Starting connection to tab ${tabId} ===`) - - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs) - newTabs.set(tabId, { - targetId: '', - state: 'connecting', - }) - return { connectedTabs: newTabs } - }) - - await ensureConnection() - - logger.debug('Calling attachTab for tab:', tabId) - const { connection } = useExtensionStore.getState() - if (!connection) return - const targetInfo = await connection.attachTab(tabId) - logger.debug('attachTab completed, updating targetId in connectedTabs map') - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs) - newTabs.set(tabId, { - targetId: targetInfo?.targetId, - state: 'connected', - }) - return { connectedTabs: newTabs, connectionState: 'connected' } - }) - - logger.debug(`=== Successfully connected to tab ${tabId} ===`) - } catch (error: any) { - logger.debug(`=== Failed to connect to tab ${tabId} ===`) - logger.debug('Error details:', error) - logger.debug('Error stack:', error.stack) - - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs) - newTabs.set(tabId, { - targetId: '', - state: 'error', - errorText: `Error: ${error.message}`, - }) - - // If we were trying to establish a connection and failed, reset the global state - // so we don't get stuck in 'reconnecting' which triggers destructive click behavior - let nextConnectionState = state.connectionState - if (state.connectionState === 'reconnecting') { - nextConnectionState = 'disconnected' - } - - return { connectedTabs: newTabs, connectionState: nextConnectionState } - }) - } -} - -async function disconnectTab(tabId: number): Promise { - logger.debug(`=== Disconnecting tab ${tabId} ===`) - - const { connectedTabs, connection } = useExtensionStore.getState() - if (!connectedTabs.has(tabId)) { - logger.debug('Tab not in connectedTabs map, ignoring disconnect') - return - } - - logger.debug('Calling detachTab on connection') - connection?.detachTab(tabId) - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs) - newTabs.delete(tabId) - return { connectedTabs: newTabs } - }) - logger.debug('Tab removed from connectedTabs map') - - const { connectedTabs: updatedTabs, connection: updatedConnection } = useExtensionStore.getState() - logger.debug('Connected tabs remaining:', updatedTabs.size) - if (updatedTabs.size === 0 && updatedConnection) { - logger.debug('No tabs remaining, closing relay connection') - updatedConnection.close('All tabs disconnected') - useExtensionStore.setState({ connection: undefined, connectionState: 'disconnected' }) - } -} - -async function reconnect(): Promise { - logger.debug('=== Starting reconnection ===') - const { connectedTabs } = useExtensionStore.getState() - logger.debug('Tabs to reconnect:', Array.from(connectedTabs.keys())) - - try { - await ensureConnection() - - const tabsToReconnect = Array.from(connectedTabs.keys()) - logger.debug('Re-attaching', tabsToReconnect.length, 'tabs') - - for (const tabId of tabsToReconnect) { - const { connectedTabs: currentTabs } = useExtensionStore.getState() - if (!currentTabs.has(tabId)) { - logger.debug('Tab', tabId, 'was manually disconnected during reconnection, skipping') - continue - } - - try { - logger.debug('Checking if tab', tabId, 'still exists') - await chrome.tabs.get(tabId) - - logger.debug('Re-attaching tab:', tabId) - const { connection } = useExtensionStore.getState() - if (!connection) return - const targetInfo = await connection.attachTab(tabId) - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs) - newTabs.set(tabId, { - targetId: targetInfo.targetId, - state: 'connected', - }) - return { connectedTabs: newTabs } - }) - logger.debug('Successfully re-attached tab:', tabId) - } catch (error: any) { - logger.debug('Failed to re-attach tab:', tabId, error.message) - useExtensionStore.setState((state) => { - const newTabs = new Map(state.connectedTabs) - newTabs.delete(tabId) - return { connectedTabs: newTabs } - }) - } - } - - const { connectedTabs: finalTabs } = useExtensionStore.getState() - logger.debug('=== Reconnection complete ===') - logger.debug('Successfully reconnected tabs:', finalTabs.size) - - if (finalTabs.size > 0) { - useExtensionStore.setState({ connectionState: 'connected' }) - logger.debug('Set connectionState to connected') - } else { - logger.debug('No tabs successfully reconnected, staying in reconnecting state') - useExtensionStore.setState({ connectionState: 'disconnected' }) - } - } catch (error: any) { - logger.debug('=== Reconnection failed ===', error) - - useExtensionStore.setState({ - connectedTabs: new Map(), - connectionState: 'error', - errorText: 'Reconnection failed - Click to retry', - }) - } -} - async function onTabRemoved(tabId: number): Promise { - const { connectedTabs } = useExtensionStore.getState() - logger.debug('Tab removed event for tab:', tabId, 'is connected:', connectedTabs.has(tabId)) - if (!connectedTabs.has(tabId)) return - + const { tabs } = store.getState() + if (!tabs.has(tabId)) return logger.debug(`Connected tab ${tabId} was closed, disconnecting`) await disconnectTab(tabId) } async function onTabActivated(activeInfo: chrome.tabs.TabActiveInfo): Promise { - logger.debug('Tab activated:', activeInfo.tabId) - useExtensionStore.setState({ currentTabId: activeInfo.tabId }) + store.setState({ currentTabId: activeInfo.tabId }) } async function onActionClicked(tab: chrome.tabs.Tab): Promise { @@ -509,8 +723,8 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise { return } - const { connectedTabs, connectionState, connection } = useExtensionStore.getState() - const tabInfo = connectedTabs.get(tab.id) + const { tabs, connectionState } = store.getState() + const tabInfo = tabs.get(tab.id) if (connectionState === 'error') { logger.debug('Global error state - retrying reconnection') @@ -520,19 +734,14 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise { if (connectionState === 'reconnecting') { logger.debug('User clicked during reconnection, canceling and disconnecting all tabs') - - const tabsToDisconnect = Array.from(connectedTabs.keys()) - - for (const tabId of tabsToDisconnect) { - connection?.detachTab(tabId) + for (const tabId of tabs.keys()) { + detachTab(tabId, true) } - - useExtensionStore.setState({ connectionState: 'disconnected', connectedTabs: new Map(), errorText: undefined }) - - if (connection) { - connection.close('User cancelled reconnection') + store.setState({ connectionState: 'disconnected', tabs: new Map(), errorText: undefined }) + if (ws) { + ws.close(1000, 'User cancelled reconnection') + ws = null } - return } @@ -549,6 +758,20 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise { } } +resetDebugger() + +chrome.runtime.onInstalled.addListener((details) => { + if (import.meta.env.TESTING) return + if (details.reason === 'install') { + void chrome.tabs.create({ url: 'welcome.html' }) + } +}) + +store.subscribe(async () => { + logger.log(store.getState()) + await updateIcons() +}) + logger.debug(`Using relay URL: ${RELAY_URL}`) chrome.tabs.onRemoved.addListener(onTabRemoved) chrome.tabs.onActivated.addListener(onTabActivated) diff --git a/extension/src/relayConnection.ts b/extension/src/relayConnection.ts deleted file mode 100644 index 57e0e01..0000000 --- a/extension/src/relayConnection.ts +++ /dev/null @@ -1,494 +0,0 @@ -/** - * 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 type { CDPEvent, Protocol } from 'playwriter/src/cdp-types' -import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/extension/protocol' - -let activeConnection: RelayConnection | undefined - -export const logger = { - log: (...args: any[]) => logToRemote('log', args), - debug: (...args: any[]) => logToRemote('debug', args), - info: (...args: any[]) => logToRemote('info', args), - warn: (...args: any[]) => logToRemote('warn', args), - error: (...args: any[]) => logToRemote('error', args), -} - -function logToRemote(level: 'log' | 'debug' | 'info' | 'warn' | 'error', args: any[]) { - // Always log to local console - console[level](...args) - - if (activeConnection) { - activeConnection.sendLog(level, args) - } -} - -function safeSerialize(arg: any): string { - if (arg === undefined) return 'undefined' - if (arg === null) return 'null' - if (typeof arg === 'function') return `[Function: ${arg.name || 'anonymous'}]` - if (typeof arg === 'symbol') return String(arg) - - if (arg instanceof Error) { - return arg.stack || arg.message || String(arg) - } - - if (typeof arg === 'object') { - try { - const seen = new WeakSet() - return JSON.stringify(arg, (key, value) => { - if (typeof value === 'object' && value !== null) { - if (seen.has(value)) return '[Circular]' - seen.add(value) - - if (value instanceof Map) { - return { - dataType: 'Map', - value: Array.from(value.entries()), - } - } - - if (value instanceof Set) { - return { - dataType: 'Set', - value: Array.from(value.values()), - } - } - } - return value - }) - } catch (e) { - return String(arg) - } - } - - return String(arg) -} - -interface AttachedTab { - debuggee: chrome.debugger.Debuggee - targetId: string - sessionId: string - targetInfo: Protocol.Target.TargetInfo -} - -export class RelayConnection { - private _attachedTabs: Map = new Map() - private _childSessions: Map = new Map() - private _nextSessionId: number = 1 - private _ws: WebSocket - private _closed = false - private _onCloseCallback?: (reason: string, code: number) => void - private _onTabDetachedCallback?: (tabId: number, reason: `${chrome.debugger.DetachReason}`) => void - private _onTabAttachedCallback?: (tabId: number, targetId: string) => void - - constructor({ - ws, - onClose, - onTabDetached, - onTabAttached, - }: { - ws: WebSocket - onClose?: (reason: string, code: number) => void - onTabDetached?: (tabId: number, reason: `${chrome.debugger.DetachReason}`) => void - onTabAttached?: (tabId: number, targetId: string) => void - }) { - this._ws = ws - this._onCloseCallback = onClose - this._onTabDetachedCallback = onTabDetached - this._onTabAttachedCallback = onTabAttached - this._ws.onmessage = async (event: MessageEvent) => { - let message: ExtensionCommandMessage - try { - message = JSON.parse(event.data) - } catch (error: any) { - logger.debug('Error parsing message:', error) - this._sendMessage({ - error: { - code: -32700, - message: `Error parsing message: ${error.message}`, - }, - }) - return - } - - // logger.debug('Received message:', message) - - const response: ExtensionResponseMessage = { - id: message.id, - } - try { - response.result = await this._handleCommand(message) - } catch (error: any) { - logger.debug('Error handling command:', error) - response.error = error.message - } - logger.debug('Sending response:', response) - this._sendMessage(response) - } - this._ws.onclose = (event: CloseEvent) => { - logger.debug('WebSocket onclose event:', { - code: event.code, - reason: event.reason, - wasClean: event.wasClean, - }) - this._onClose(event.reason, event.code) - } - this._ws.onerror = (event: Event) => { - logger.debug('WebSocket onerror event:', event) - } - chrome.debugger.onEvent.addListener(this._onDebuggerEvent) - chrome.debugger.onDetach.addListener(this._onDebuggerDetach) - logger.debug('RelayConnection created, WebSocket readyState:', this._ws.readyState) - activeConnection = this - } - - sendLog(level: string, args: any[]) { - this._sendMessage({ - method: 'log', - params: { - level, - args: args.map((arg) => safeSerialize(arg)), - }, - }) - } - - async attachTab(tabId: number): Promise { - const debuggee = { tabId } - - logger.debug('Attaching debugger to tab:', tabId, 'WebSocket state:', this._ws.readyState) - - try { - await chrome.debugger.attach(debuggee, '1.3') - logger.debug('Debugger attached successfully to tab:', tabId) - } catch (error: any) { - logger.debug('ERROR attaching debugger to tab:', tabId, error) - throw error - } - - logger.debug('Sending Target.getTargetInfo command for tab:', tabId) - const result = (await chrome.debugger.sendCommand( - debuggee, - 'Target.getTargetInfo', - )) as Protocol.Target.GetTargetInfoResponse - - logger.debug('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, - }) - - logger.debug('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, - }, - }, - }) - - logger.debug('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId) - this._onTabAttachedCallback?.(tabId, targetInfo.targetId) - return targetInfo - } - - detachTab(tabId: number): void { - this._cleanupTab(tabId, true) - } - - private _cleanupTab(tabId: number, shouldDetachDebugger: boolean): void { - const tab = this._attachedTabs.get(tabId) - if (!tab) { - logger.debug('cleanupTab: tab not found in map:', tabId) - return - } - - logger.debug('Cleaning up tab:', tabId, 'sessionId:', tab.sessionId, 'shouldDetach:', shouldDetachDebugger) - - this._sendMessage({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { - sessionId: tab.sessionId, - targetId: tab.targetId, - }, - }, - }) - - this._attachedTabs.delete(tabId) - - for (const [childSessionId, parentTabId] of this._childSessions.entries()) { - if (parentTabId === tabId) { - logger.debug('Cleaning up child session:', childSessionId, 'for tab:', tabId) - this._childSessions.delete(childSessionId) - } - } - - logger.debug('Removed tab from _attachedTabs map. Remaining tabs:', this._attachedTabs.size) - - if (shouldDetachDebugger) { - chrome.debugger - .detach(tab.debuggee) - .then(() => { - logger.debug('Successfully detached debugger from tab:', tabId) - }) - .catch((err) => { - logger.debug('Error detaching debugger from tab:', tabId, err.message) - }) - } - } - - close(message: string): void { - logger.debug('Closing RelayConnection, reason:', message, 'current state:', this._ws.readyState) - this._ws.close(1000, message) - this._onClose(message, 1000) - } - - private _onClose(reason: string = 'Unknown', code: number = 1000) { - if (this._closed) { - logger.debug('_onClose called but already closed') - return - } - - logger.debug('Connection closing, attached tabs count:', this._attachedTabs.size) - this._closed = true - - chrome.debugger.onEvent.removeListener(this._onDebuggerEvent) - chrome.debugger.onDetach.removeListener(this._onDebuggerDetach) - - const tabIds = Array.from(this._attachedTabs.keys()) - logger.debug('Detaching all tabs:', tabIds) - - for (const [tabId, tab] of this._attachedTabs) { - logger.debug('Detaching debugger from tab:', tabId) - chrome.debugger - .detach(tab.debuggee) - .then(() => { - logger.debug('Successfully detached from tab:', tabId) - }) - .catch((err) => { - logger.debug('Error detaching from tab:', tabId, err.message) - }) - } - - this._attachedTabs.clear() - logger.debug('All tabs cleared from map. Chrome automation bar should disappear in a few seconds.') - - logger.debug('Connection closed, calling onClose callback') - if (activeConnection === this) { - activeConnection = undefined - } - this._onCloseCallback?.(reason, code) - } - - private _onDebuggerEvent = (source: chrome.debugger.DebuggerSession, method: string, params: any): void => { - const tab = this._attachedTabs.get(source.tabId!) - if (!tab) return - - // Track execution contexts so we can replay them when Playwright reconnects. - // Chrome's debugger only sends Runtime.executionContextCreated events once per context, - // not on every Runtime.enable call. We cache them here and replay on reconnection. - - logger.debug('Forwarding CDP event:', method, 'from tab:', source.tabId) - - if (method === 'Target.attachedToTarget' && params?.sessionId) { - logger.debug('Child target attached:', params.sessionId, 'for tab:', source.tabId) - this._childSessions.set(params.sessionId, source.tabId!) - } - - if (method === 'Target.detachedFromTarget' && params?.sessionId) { - logger.debug('Child target detached:', params.sessionId) - this._childSessions.delete(params.sessionId) - } - - this._sendMessage({ - method: 'forwardCDPEvent', - params: { - sessionId: source.sessionId || tab.sessionId, - method, - params, - }, - }) - } - - private _onDebuggerDetach = (source: chrome.debugger.Debuggee, reason: `${chrome.debugger.DetachReason}`): void => { - const tabId = source.tabId - logger.debug( - '_onDebuggerDetach called for tab:', - tabId, - 'reason:', - reason, - 'isAttached:', - tabId ? this._attachedTabs.has(tabId) : false, - ) - - if (!tabId || !this._attachedTabs.has(tabId)) { - logger.debug('Ignoring debugger detach event for untracked tab:', tabId) - return - } - - logger.debug(`Manual debugger detachment detected for tab ${tabId}: ${reason}`) - logger.debug('User closed debugger via Chrome automation bar, calling onTabDetached callback') - this._onTabDetachedCallback?.(tabId, reason) - - this._cleanupTab(tabId, false) - } - - private async _handleCommand(msg: ExtensionCommandMessage): Promise { - if (msg.method === 'forwardCDPCommand') { - let targetTab: AttachedTab | undefined - - // find tab by sessionId - if (msg.params.sessionId) { - for (const [tabId, tab] of this._attachedTabs) { - if (tab.sessionId === msg.params.sessionId) { - targetTab = tab - break - } - } - } - - // find parent tab if this is a child target - if (!targetTab && msg.params.sessionId) { - const parentTabId = this._childSessions.get(msg.params.sessionId) - if (parentTabId) { - targetTab = this._attachedTabs.get(parentTabId) - logger.debug('Found parent tab for child session:', msg.params.sessionId, 'tabId:', parentTabId) - } - } - - // find tab by targetId in params - if (!targetTab && msg.params.params && 'targetId' in msg.params.params && msg.params.params.targetId) { - const paramTargetId = msg.params.params.targetId as string - for (const [tabId, tab] of this._attachedTabs) { - if (tab.targetId === paramTargetId) { - targetTab = tab - logger.debug('Found tab for targetId:', paramTargetId, 'tabId:', tabId) - break - } - } - } - - switch (msg.params.method) { - // CRITICAL FIX FOR PLAYWRIGHT HANGS: - // When Playwright connects to an existing page, it sends 'Runtime.enable' and waits for - // 'Runtime.executionContextCreated' events to map out available execution contexts (frames, workers). - // - // However, if the Runtime domain is already enabled or if Chrome doesn't re-emit these events - // for existing contexts upon a standard 'Runtime.enable' call, Playwright never gets the - // context IDs it needs. This causes commands like `page.evaluate()` to hang indefinitely - // because Playwright is waiting for a context that it believes hasn't been created yet. - // - // To fix this, we intercept 'Runtime.enable' and force a reset: - // 1. We send 'Runtime.disable'. This clears Chrome's internal Runtime state for this session. - // 2. We wait a brief moment (100ms) to ensure the disable command propagates. - // 3. Then we allow the original 'Runtime.enable' command to proceed. - // - // This sequence forces Chrome to treat the enablement as a fresh start, causing it to - // re-scan and emit 'Runtime.executionContextCreated' events for ALL existing contexts. - // Playwright receives these events, populates its context map, and the hang is resolved. - case 'Runtime.enable': { - if (!targetTab?.debuggee) throw new Error(`No debuggee found for tab (sessionId: ${msg.params.sessionId}, tabId: ${targetTab?.debuggee?.tabId ?? 'unknown'})`) - try { - await chrome.debugger.sendCommand(targetTab?.debuggee!, 'Runtime.disable') - await new Promise((resolve) => setTimeout(resolve, 200)) - } catch (e) { - logger.debug('Error disabling Runtime (ignoring):', e) - } - const result = await chrome.debugger.sendCommand(targetTab?.debuggee!, 'Runtime.enable', msg.params.params) - return result - } - case 'Target.createTarget': { - const url = msg.params.params?.url || 'about:blank' - logger.debug('Creating new tab with URL:', url) - - const tab = await chrome.tabs.create({ url, active: false }) - if (!tab.id) { - throw new Error('Failed to create tab') - } - - logger.debug('Created tab:', tab.id, 'waiting for it to load...') - - await new Promise((resolve) => setTimeout(resolve, 100)) - - const targetInfo = await this.attachTab(tab.id) - - return { targetId: targetInfo.targetId } satisfies Protocol.Target.CreateTargetResponse - } - - case 'Target.closeTarget': { - if (!targetTab || !targetTab.debuggee.tabId) { - logger.log(`Target not found: ${msg.params.params?.targetId}`) - return { success: false } satisfies Protocol.Target.CloseTargetResponse - } - await chrome.tabs.remove(targetTab.debuggee.tabId!) - return { success: true } satisfies Protocol.Target.CloseTargetResponse - } - } - - if (!targetTab) { - throw new Error( - `No tab found for method ${msg.params.method} sessionId: ${msg.params.sessionId} params: ${JSON.stringify(msg.params.params || null)}`, - ) - } - - logger.debug('CDP command:', msg.params.method, 'for tab:', targetTab.debuggee.tabId) - - const debuggerSession: chrome.debugger.DebuggerSession = { - ...targetTab.debuggee, - sessionId: msg.params.sessionId !== targetTab.sessionId ? msg.params.sessionId : undefined, - } - - const result = await chrome.debugger.sendCommand(debuggerSession, msg.params.method, msg.params.params) - - return result - } - } - - private _sendMessage(message: any): void { - if (this._ws.readyState === WebSocket.OPEN) { - try { - this._ws.send(JSON.stringify(message)) - // logger.debug('Message sent successfully, type:', message.method || 'response'); - } catch (error: any) { - // Use console directly to avoid infinite recursion if logger tries to send log over this same connection - console.debug('ERROR sending message:', error, 'message type:', message.method || 'response') - } - } else { - // Use console directly to avoid infinite recursion - console.debug( - 'Cannot send message, WebSocket not open. State:', - this._ws.readyState, - 'message type:', - message.method || 'response', - ) - } - } -} diff --git a/extension/src/types.ts b/extension/src/types.ts index 6b46740..6239526 100644 --- a/extension/src/types.ts +++ b/extension/src/types.ts @@ -1,17 +1,15 @@ -import type { RelayConnection } from './relayConnection' - export type ConnectionState = 'disconnected' | 'reconnecting' | 'connected' | 'error' export type TabState = 'connecting' | 'connected' | 'error' export interface TabInfo { - targetId: string + sessionId?: string + targetId?: string state: TabState errorText?: string } export interface ExtensionState { - connection: RelayConnection | undefined - connectedTabs: Map + tabs: Map connectionState: ConnectionState currentTabId: number | undefined errorText: string | undefined diff --git a/playwriter/package.json b/playwriter/package.json index 6ce2c01..1664afd 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -40,6 +40,7 @@ "@modelcontextprotocol/sdk": "^1.21.1", "chalk": "^5.6.2", "devtools-protocol": "^0.0.1543509", + "diff": "^8.0.2", "hono": "^4.10.6", "playwright-core": "^1.56.1", "string-dedent": "^3.0.2", diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index 004cbed..8184fd5 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -226,9 +226,9 @@ describe('MCP Server Tests', () => { const tabs = await chrome.tabs.query({}) const testTab = tabs.find((t: any) => t.url?.includes('mcp-test')) return { - connected: !!testTab && !!testTab.id && state.connectedTabs.has(testTab.id), + connected: !!testTab && !!testTab.id && state.tabs.has(testTab.id), tabId: testTab?.id, - tabInfo: testTab?.id ? state.connectedTabs.get(testTab.id) : null, + tabInfo: testTab?.id ? state.tabs.get(testTab.id) : null, connectionState: state.connectionState } }) @@ -581,8 +581,8 @@ describe('MCP Server Tests', () => { const tabA = tabs.find((t: any) => t.url?.includes('tab-a')) const tabB = tabs.find((t: any) => t.url?.includes('tab-b')) return { - idA: state.connectedTabs.get(tabA?.id ?? -1)?.targetId, - idB: state.connectedTabs.get(tabB?.id ?? -1)?.targetId + idA: state.tabs.get(tabA?.id ?? -1)?.targetId, + idB: state.tabs.get(tabB?.id ?? -1)?.targetId } }) @@ -667,8 +667,8 @@ describe('MCP Server Tests', () => { const tabA = tabs.find((t: any) => t.url?.includes('tab-a')) const tabB = tabs.find((t: any) => t.url?.includes('tab-b')) return { - idA: state.connectedTabs.get(tabA?.id ?? -1)?.targetId, - idB: state.connectedTabs.get(tabB?.id ?? -1)?.targetId + idA: state.tabs.get(tabA?.id ?? -1)?.targetId, + idB: state.tabs.get(tabB?.id ?? -1)?.targetId } }) @@ -961,7 +961,7 @@ describe('MCP Server Tests', () => { name: 'execute', arguments: { code: js` - const logs = await getLatestLogs({ searchFilter: 'error' }); + const logs = await getLatestLogs({ search: 'error' }); logs.forEach(log => console.log(log)); `, }, diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index c1c7c4a..6081002 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -8,6 +8,7 @@ import { spawn } from 'node:child_process' import { createRequire } from 'node:module' import vm from 'node:vm' import dedent from 'string-dedent' +import { createPatch } from 'diff' import { getCdpUrl } from './utils.js' const require = createRequire(import.meta.url) @@ -51,6 +52,7 @@ interface VMContext { page: Page search?: string | RegExp contextLines?: number + showDiffSinceLastCall?: boolean }) => Promise getLocatorStringForElement: (element: any) => Promise resetPlaywright: () => Promise<{ page: Page; context: BrowserContext }> @@ -77,6 +79,9 @@ const userState: Record = {} const browserLogs: Map = new Map() const MAX_LOGS_PER_PAGE = 5000 +// Store last accessibility snapshot per page for diff feature +const lastSnapshots: WeakMap = new WeakMap() + const RELAY_PORT = 19988 const NO_TABS_ERROR = `No browser tabs are connected. Please install and enable the Playwriter extension on at least one tab: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe` @@ -390,12 +395,30 @@ server.tool( page: Page search?: string | RegExp contextLines?: number + showDiffSinceLastCall?: boolean }) => { - const { page: targetPage, search, contextLines = 10 } = options + const { page: targetPage, search, contextLines = 10, showDiffSinceLastCall = false } = options if ((targetPage as any)._snapshotForAI) { const snapshot = await (targetPage as any)._snapshotForAI() const snapshotStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2) + if (showDiffSinceLastCall) { + const previousSnapshot = lastSnapshots.get(targetPage) + lastSnapshots.set(targetPage, snapshotStr) + + if (!previousSnapshot) { + return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.' + } + + const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current') + if (patch.split('\n').length <= 4) { + return 'No changes detected since last snapshot' + } + return patch + } + + lastSnapshots.set(targetPage, snapshotStr) + if (!search) { return snapshotStr } diff --git a/playwriter/src/prompt.md b/playwriter/src/prompt.md index 9ff1913..93cbfbe 100644 --- a/playwriter/src/prompt.md +++ b/playwriter/src/prompt.md @@ -72,10 +72,11 @@ always detach event listener you create at the end of a message using `page.remo you have access to some functions in addition to playwright methods: -- `async accessibilitySnapshot({ page, search, contextLines })`: gets a human readable snapshot of clickable elements on the page. useful to see the overall structure of the page and what elements you can interact with. +- `async accessibilitySnapshot({ page, search, contextLines, showDiffSinceLastCall })`: gets a human readable snapshot of clickable elements on the page. useful to see the overall structure of the page and what elements you can interact with. - `page`: the page object to snapshot - `search`: (optional) a string or regex to filter the snapshot. If provided, returns the first 10 matches with surrounding context - `contextLines`: (optional) number of lines of context to show around each match (default: 10) + - `showDiffSinceLastCall`: (optional) if true, returns a unified diff patch showing only changes since the last snapshot call for this page. Disables search when enabled. Useful to see what changed after an action. - `getLatestLogs({ page, count, search })`: retrieves browser console logs. The system automatically captures and stores up to 5000 logs per page. Logs are cleared when a page reloads or navigates. - `page`: (optional) filter logs by a specific page instance. Only returns logs from that page - `count`: (optional) limit number of logs to return. If not specified, returns all available logs diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f13591..b2ed093 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: devtools-protocol: specifier: ^0.0.1543509 version: 0.0.1543509 + diff: + specifier: ^8.0.2 + version: 8.0.2 hono: specifier: ^4.10.6 version: 4.10.6 @@ -931,6 +934,10 @@ packages: devtools-protocol@0.0.1543509: resolution: {integrity: sha512-JlHmIPtgDeqcL2uwPA7xryDNSb1ug9h11IexYQUG98vcyV6/L3taLRECgUk/B/7yQhXC/sgBzKj9kaB+bxmdWg==} + diff@8.0.2: + resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -2653,6 +2660,8 @@ snapshots: devtools-protocol@0.0.1543509: {} + diff@8.0.2: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0