diff --git a/extension/manifest.json b/extension/manifest.json index 53d540c..c616f2b 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Playwriter MCP", - "version": "0.0.64", + "version": "0.0.65", "description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.", "permissions": ["debugger", "tabGroups", "contextMenus", "tabs"], "host_permissions": [""], diff --git a/extension/package.json b/extension/package.json index d463a32..b50dbb3 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "mcp-extension", - "version": "0.0.64", + "version": "0.0.65", "description": "Playwright MCP Browser Extension", "private": true, "repository": { diff --git a/extension/src/background.ts b/extension/src/background.ts index bdc3baf..0276ddf 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -16,12 +16,12 @@ let ws: WebSocket | null = null let childSessions: Map = new Map() let nextSessionId = 1 let playwriterGroupId: number | null = null -let syncTabGroupQueue: Promise = Promise.resolve() +let tabGroupQueue: Promise = Promise.resolve() let replacedRetryInterval: ReturnType | null = null const store = createStore(() => ({ tabs: new Map(), - connectionState: 'disconnected', + connectionState: 'idle', currentTabId: undefined, errorText: undefined, })) @@ -131,6 +131,22 @@ async function syncTabGroup(): Promise { const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' }) + // Check for no connected tabs FIRST, before setting playwriterGroupId + // This prevents a race condition where onTabUpdated sees playwriterGroupId !== null + if (connectedTabIds.length === 0) { + for (const group of existingGroups) { + const tabsInGroup = await chrome.tabs.query({ groupId: group.id }) + for (const tab of tabsInGroup) { + if (tab.id) { + await chrome.tabs.ungroup(tab.id) + } + } + logger.debug('Cleared playwriter group:', group.id) + } + playwriterGroupId = null + return + } + if (existingGroups.length > 1) { const [keep, ...duplicates] = existingGroups for (const group of duplicates) { @@ -147,20 +163,6 @@ async function syncTabGroup(): Promise { playwriterGroupId = existingGroups[0].id } - if (connectedTabIds.length === 0) { - for (const group of existingGroups) { - const tabsInGroup = await chrome.tabs.query({ groupId: group.id }) - for (const tab of tabsInGroup) { - if (tab.id) { - await chrome.tabs.ungroup(tab.id) - } - } - logger.debug('Cleared playwriter group:', group.id) - } - playwriterGroupId = null - return - } - const allTabs = await chrome.tabs.query({}) const tabsInGroup = allTabs.filter((t) => t.groupId === playwriterGroupId && t.id !== undefined) const tabIdsInGroup = new Set(tabsInGroup.map((t) => t.id!)) @@ -372,7 +374,7 @@ function onDebuggerDetach(source: chrome.debugger.Debuggee, reason: `${chrome.de if (reason === chrome.debugger.DetachReason.CANCELED_BY_USER) { // Chrome's debugger info bar cancellation detaches ALL debugger sessions, not just one tab - store.setState({ connectionState: 'disconnected', errorText: undefined }) + store.setState({ connectionState: 'idle', errorText: undefined }) } } @@ -478,7 +480,7 @@ function closeConnection(reason: string): void { }) } - store.setState({ tabs: new Map(), connectionState: 'disconnected', errorText: undefined }) + store.setState({ tabs: new Map(), connectionState: 'idle', errorText: undefined }) childSessions.clear() if (ws) { @@ -491,7 +493,7 @@ function startReplacedRetryLoop(): void { if (replacedRetryInterval) return logger.debug('Starting replaced retry loop (every 3 seconds)') replacedRetryInterval = setInterval(async () => { - if (ws?.readyState === WebSocket.OPEN || store.getState().connectionState !== 'error') { + if (ws?.readyState === WebSocket.OPEN || store.getState().connectionState !== 'extension-replaced') { clearInterval(replacedRetryInterval!) replacedRetryInterval = null logger.debug('Stopped replaced retry loop') @@ -501,7 +503,7 @@ function startReplacedRetryLoop(): void { const response = await fetch(`http://localhost:${RELAY_PORT}/extension/status`) const data = await response.json() if (!data.connected) { - store.setState({ connectionState: 'disconnected', errorText: undefined }) + store.setState({ connectionState: 'idle', errorText: undefined }) logger.debug('Extension slot is free, cleared error state') } else { logger.debug('Extension slot still taken, will retry...') @@ -533,26 +535,21 @@ function handleConnectionClose(reason: string, code: number): void { logger.debug('Connection replaced by another extension instance') store.setState({ tabs: new Map(), - connectionState: 'error', + connectionState: 'extension-replaced', errorText: 'Disconnected: Replaced by another extension', }) startReplacedRetryLoop() return } - if (tabs.size > 0) { - logger.debug('Tabs still connected, triggering reconnection') - store.setState((state) => { - const newTabs = new Map(state.tabs) - for (const [tabId, tab] of newTabs) { - newTabs.set(tabId, { ...tab, state: 'connecting' }) - } - return { tabs: newTabs } - }) - void reconnect() - } else { - store.setState({ connectionState: 'disconnected', errorText: undefined }) - } + // For normal disconnects, set tabs to 'connecting' state and let maintainConnection handle reconnect + store.setState((state) => { + const newTabs = new Map(state.tabs) + for (const [tabId, tab] of newTabs) { + newTabs.set(tabId, { ...tab, state: 'connecting' }) + } + return { tabs: newTabs, connectionState: 'idle', errorText: undefined } + }) } async function ensureConnection(): Promise { @@ -562,14 +559,18 @@ async function ensureConnection(): Promise { } logger.debug(`Waiting for server at http://localhost:${RELAY_PORT}...`) - store.setState({ connectionState: 'reconnecting' }) - while (true) { + // Retry for up to 30 seconds, then give up (maintainConnection will retry later) + const maxAttempts = 30 + for (let attempt = 0; attempt < maxAttempts; attempt++) { try { await fetch(`http://localhost:${RELAY_PORT}`, { method: 'HEAD' }) logger.debug('Server is available') break } catch { + if (attempt === maxAttempts - 1) { + throw new Error('Server not available after 30 seconds') + } logger.debug('Server not available, retrying in 1 second...') await sleep(1000) } @@ -628,6 +629,25 @@ async function ensureConnection(): Promise { return } + // Handle createInitialTab - create a new tab when Playwright connects and no tabs exist + if (message.method === 'createInitialTab') { + try { + logger.debug('Creating initial tab for Playwright client') + const tab = await chrome.tabs.create({ url: 'about:blank', active: false }) + if (tab.id) { + await connectTab(tab.id) + logger.debug('Initial tab created and connected:', tab.id) + sendMessage({ id: message.id, result: { success: true, tabId: tab.id } }) + } else { + throw new Error('Failed to create tab - no tab ID returned') + } + } catch (error: any) { + logger.debug('Failed to create initial tab:', error) + sendMessage({ id: message.id, error: error.message }) + } + return + } + const response: ExtensionResponseMessage = { id: message.id } try { response.result = await handleCommand(message as ExtensionCommandMessage) @@ -653,6 +673,53 @@ async function ensureConnection(): Promise { logger.debug('Connection established') } +async function maintainConnection(): Promise { + while (true) { + if (ws?.readyState === WebSocket.OPEN) { + await sleep(1000) + continue + } + + // Don't auto-reconnect if replaced by another extension + if (store.getState().connectionState === 'extension-replaced') { + await sleep(1000) + continue + } + + // Try to connect silently in background - don't show 'connecting' badge + // Individual tab states will show 'connecting' when user explicitly clicks + try { + await ensureConnection() + store.setState({ connectionState: 'connected' }) + + // Re-attach any tabs that were in 'connecting' state (from a previous disconnect) + const tabsToReattach = Array.from(store.getState().tabs.entries()) + .filter(([_, tab]) => tab.state === 'connecting') + .map(([tabId]) => tabId) + + for (const tabId of tabsToReattach) { + 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 } + }) + } + } + } catch (error: any) { + logger.debug('Connection attempt failed:', error.message) + store.setState({ connectionState: 'idle' }) + } + + await sleep(5000) + } +} + async function connectTab(tabId: number): Promise { try { logger.debug(`Starting connection to tab ${tabId}`) @@ -673,8 +740,7 @@ async function connectTab(tabId: number): Promise { 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 } + return { tabs: newTabs } }) } } @@ -689,58 +755,7 @@ async function disconnectTab(tabId: number): Promise { } detachTab(tabId, true) - - const { tabs: updatedTabs } = store.getState() - if (updatedTabs.size === 0 && ws) { - logger.debug('No tabs remaining, closing connection') - closeConnection('All tabs disconnected') - } -} - -async function reconnect(): Promise { - logger.debug('Starting reconnection') - store.setState({ connectionState: 'reconnecting', errorText: undefined }) - 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', errorText: undefined }) - } else { - store.setState({ connectionState: 'disconnected', errorText: undefined }) - } - } catch (error: any) { - logger.debug('Reconnection failed:', error) - store.setState({ - tabs: new Map(), - connectionState: 'error', - errorText: 'Reconnection failed - Click to retry', - }) - } + // WS connection is maintained even with no tabs - maintainConnection handles it } async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; state: ExtensionState }> { @@ -754,7 +769,7 @@ async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; st const check = () => { const state = store.getState() const tabInfo = state.tabs.get(tab.id!) - if (tabInfo?.state === 'connecting' || state.connectionState === 'reconnecting') { + if (tabInfo?.state === 'connecting') { setTimeout(check, 100) return } @@ -769,20 +784,16 @@ async function toggleExtensionForActiveTab(): Promise<{ isConnected: boolean; st } async function disconnectEverything(): Promise { - const { tabs } = store.getState() - - for (const tabId of tabs.keys()) { - await disconnectTab(tabId) - } - - if (ws) { - closeConnection('Manual full disconnect') - store.setState({ - connectionState: 'disconnected', - tabs: new Map(), - errorText: undefined, - }) - } + // Queue disconnect operation to serialize with other tab group operations + tabGroupQueue = tabGroupQueue.then(async () => { + playwriterGroupId = null + const { tabs } = store.getState() + for (const tabId of tabs.keys()) { + await disconnectTab(tabId) + } + }) + await tabGroupQueue + // WS connection is maintained - maintainConnection handles it } async function resetDebugger(): Promise { @@ -825,7 +836,7 @@ const icons = { badgeText: '...', badgeColor: [64, 64, 64, 255] as [number, number, number, number], }, - disconnected: { + idle: { path: { '16': '/icons/icon-black-16.png', '32': '/icons/icon-black-32.png', @@ -847,7 +858,18 @@ const icons = { badgeText: '', badgeColor: [64, 64, 64, 255] as [number, number, number, number], }, - error: { + extensionReplaced: { + path: { + '16': '/icons/icon-gray-16.png', + '32': '/icons/icon-gray-32.png', + '48': '/icons/icon-gray-48.png', + '128': '/icons/icon-gray-128.png', + }, + title: 'Replaced by another extension - Click to retry', + badgeText: '!', + badgeColor: [220, 38, 38, 255] as [number, number, number, number], + }, + tabError: { path: { '16': '/icons/icon-gray-16.png', '32': '/icons/icon-gray-32.png', @@ -875,23 +897,22 @@ async function updateIcons(): Promise { const tabUrl = tabId !== undefined ? tabUrlMap.get(tabId) : undefined const iconConfig = (() => { - if (connectionState === 'error') return icons.error + if (connectionState === 'extension-replaced') return icons.extensionReplaced if (tabId !== undefined && isRestrictedUrl(tabUrl)) return icons.restricted - if (connectionState === 'reconnecting') return icons.connecting - if (tabInfo?.state === 'error') return icons.error + if (tabInfo?.state === 'error') return icons.tabError if (tabInfo?.state === 'connecting') return icons.connecting if (tabInfo?.state === 'connected') return icons.connected - return icons.disconnected + return icons.idle })() const title = (() => { - if (connectionState === 'error' && errorText) return errorText + if (connectionState === 'extension-replaced' && errorText) return errorText if (tabInfo?.errorText) return tabInfo.errorText return iconConfig.title })() const badgeText = (() => { - if (iconConfig === icons.connected || iconConfig === icons.disconnected || iconConfig === icons.restricted) { + if (iconConfig === icons.connected || iconConfig === icons.idle || iconConfig === icons.restricted) { return connectedCount > 0 ? String(connectedCount) : '' } return iconConfig.badgeText @@ -929,22 +950,11 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise { const { tabs, connectionState } = store.getState() const tabInfo = tabs.get(tab.id) - if (connectionState === 'error') { - logger.debug('Global error state - retrying reconnection') - await reconnect() - return - } - - if (connectionState === 'reconnecting') { - logger.debug('User clicked during reconnection, canceling and disconnecting all tabs') - for (const tabId of tabs.keys()) { - detachTab(tabId, true) - } - store.setState({ connectionState: 'disconnected', tabs: new Map(), errorText: undefined }) - if (ws) { - ws.close(1000, 'User cancelled reconnection') - ws = null - } + // If in extension-replaced state, clear it to allow reconnection + if (connectionState === 'extension-replaced') { + logger.debug('Clearing extension-replaced state, will retry connection') + store.setState({ connectionState: 'idle', errorText: undefined }) + // maintainConnection will pick up and retry return } @@ -967,6 +977,7 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise { } resetDebugger() +maintainConnection() chrome.contextMenus.remove('playwriter-pin-element').catch(() => {}).finally(() => { chrome.contextMenus.create({ @@ -1000,7 +1011,7 @@ store.subscribe((state, prevState) => { updateContextMenuVisibility() const tabsChanged = serializeTabs(state.tabs) !== serializeTabs(prevState.tabs) if (tabsChanged) { - syncTabGroupQueue = syncTabGroupQueue.then(syncTabGroup).catch((e) => { + tabGroupQueue = tabGroupQueue.then(syncTabGroup).catch((e) => { logger.debug('syncTabGroup error:', e) }) } @@ -1012,22 +1023,31 @@ chrome.tabs.onActivated.addListener(onTabActivated) chrome.action.onClicked.addListener(onActionClicked) chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { void updateIcons() - if (changeInfo.groupId !== undefined && playwriterGroupId !== null) { - const { tabs } = store.getState() - if (changeInfo.groupId === playwriterGroupId) { - if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) { - logger.debug('Tab manually added to playwriter group:', tabId) - void connectTab(tabId) - } - } else if (tabs.has(tabId)) { - const tabInfo = tabs.get(tabId) - if (tabInfo?.state === 'connecting') { - logger.debug('Tab removed from group while connecting, ignoring:', tabId) + if (changeInfo.groupId !== undefined) { + // Queue tab group operations to serialize with syncTabGroup and disconnectEverything + tabGroupQueue = tabGroupQueue.then(async () => { + // Re-check conditions after queue - state may have changed + if (playwriterGroupId === null) { return } - logger.debug('Tab manually removed from playwriter group:', tabId) - void disconnectTab(tabId) - } + const { tabs } = store.getState() + if (changeInfo.groupId === playwriterGroupId) { + if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) { + logger.debug('Tab manually added to playwriter group:', tabId) + await connectTab(tabId) + } + } else if (tabs.has(tabId)) { + const tabInfo = tabs.get(tabId) + if (tabInfo?.state === 'connecting') { + logger.debug('Tab removed from group while connecting, ignoring:', tabId) + return + } + logger.debug('Tab manually removed from playwriter group:', tabId) + await disconnectTab(tabId) + } + }).catch((e) => { + logger.debug('onTabUpdated handler error:', e) + }) } }) diff --git a/extension/src/types.ts b/extension/src/types.ts index 196a419..011c466 100644 --- a/extension/src/types.ts +++ b/extension/src/types.ts @@ -1,4 +1,4 @@ -export type ConnectionState = 'disconnected' | 'reconnecting' | 'connected' | 'error' +export type ConnectionState = 'idle' | 'connected' | 'extension-replaced' export type TabState = 'connecting' | 'connected' | 'error' export interface TabInfo { diff --git a/playwriter/CHANGELOG.md b/playwriter/CHANGELOG.md index 06fc2d3..45857c1 100644 --- a/playwriter/CHANGELOG.md +++ b/playwriter/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.0.35 + +### Patch Changes + +- **Persistent WS connection**: Extension now connects to relay server at startup and maintains connection indefinitely, retrying every 5 seconds silently in background +- **Silent background retry**: Connecting badge only shows when user explicitly clicks to attach a tab, not during background reconnection attempts +- **Fixed tab group race condition**: All tab group operations now queue through `tabGroupQueue` to prevent race conditions between `syncTabGroup`, `disconnectEverything`, and `onTabUpdated` +- **Simplified connection states**: Renamed `'disconnected'` to `'idle'`, removed global `'connecting'` state (only individual tabs show connecting state) +- **Auto-create initial tab**: When `PLAYWRITER_AUTO_ENABLE` env var is set, automatically creates an about:blank tab when Playwright connects and no tabs exist + ## 0.0.34 ### Patch Changes diff --git a/playwriter/package.json b/playwriter/package.json index fd6739b..95a9a64 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -1,7 +1,7 @@ { "name": "playwriter", "description": "", - "version": "0.0.34", + "version": "0.0.35", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index 12cbd17..7c6bf2a 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -361,15 +361,40 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127. const clientId = c.req.param('clientId') || 'default' return { - onOpen(_event, ws) { + async onOpen(_event, ws) { if (playwrightClients.has(clientId)) { logger?.log(chalk.red(`Rejecting duplicate client ID: ${clientId}`)) ws.close(1000, 'Client ID already connected') return } + // Add client first so it can receive Target.attachedToTarget events playwrightClients.set(clientId, { id: clientId, ws }) logger?.log(chalk.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total)`)) + + // Auto-create initial tab if enabled and no targets exist + if (process.env.PLAYWRITER_AUTO_ENABLE && extensionWs && connectedTargets.size === 0) { + try { + logger?.log(chalk.blue('Auto-creating initial tab for Playwright client')) + await sendToExtension({ method: 'createInitialTab', timeout: 10000 }) + // Wait for Target.attachedToTarget event to populate connectedTargets + await new Promise((resolve) => { + const checkTargets = () => { + if (connectedTargets.size > 0) { + resolve(undefined) + } else { + setTimeout(checkTargets, 50) + } + } + checkTargets() + // Timeout after 5 seconds + setTimeout(() => resolve(undefined), 5000) + }) + logger?.log(chalk.blue(`Auto-created tab, now have ${connectedTargets.size} targets`)) + } catch (e) { + logger?.error('Failed to auto-create initial tab:', e) + } + } }, async onMessage(event, ws) { diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index 8cb1a2d..ea2dad8 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -217,8 +217,8 @@ describe('MCP Server Tests', () => { Return value: { - \"url\": \"https://example.com/\", - \"title\": \"Example Domain\" + "url": "https://example.com/", + "title": "Example Domain" }", "type": "text", }, @@ -1943,8 +1943,8 @@ describe('MCP Server Tests', () => { { "text": "Return value: { - "error": "Page not found", - "urls": [] + "matchesDark": false, + "matchesLight": true }", "type": "text", }, @@ -2881,3 +2881,71 @@ describe('CDP Session Tests', () => { await page.close() }, 60000) }) + +describe('Auto-enable Tests', () => { + let testCtx: TestContext | null = null + + // Set env var before any setup runs + process.env.PLAYWRITER_AUTO_ENABLE = '1' + + beforeAll(async () => { + testCtx = await setupTestContext({ tempDirPrefix: 'pw-auto-test-' }) + + // Disconnect all tabs to start with a clean state + const serviceWorker = await getExtensionServiceWorker(testCtx.browserContext) + await serviceWorker.evaluate(async () => { + await globalThis.disconnectEverything() + }) + await new Promise(r => setTimeout(r, 100)) + }, 600000) + + afterAll(async () => { + delete process.env.PLAYWRITER_AUTO_ENABLE + await cleanupTestContext(testCtx) + testCtx = null + }) + + const getBrowserContext = () => { + if (!testCtx?.browserContext) throw new Error('Browser not initialized') + return testCtx.browserContext + } + + it('should auto-create a tab when Playwright connects and no tabs exist', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + // Verify no tabs are connected + const tabCountBefore = await serviceWorker.evaluate(() => { + const state = globalThis.getExtensionState() + return state.tabs.size + }) + expect(tabCountBefore).toBe(0) + + // Connect Playwright - this should trigger auto-create + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + + // Wait for auto-create to complete (async onOpen may not be fully awaited) + await new Promise(r => setTimeout(r, 500)) + + // Verify a page was auto-created + const pages = browser.contexts()[0].pages() + expect(pages.length).toBeGreaterThan(0) + + const autoCreatedPage = pages[0] + expect(autoCreatedPage.url()).toBe('about:blank') + + // Verify extension state shows the tab as connected + const tabCountAfter = await serviceWorker.evaluate(() => { + const state = globalThis.getExtensionState() + return state.tabs.size + }) + expect(tabCountAfter).toBe(1) + + // Verify we can interact with the auto-created page + await autoCreatedPage.setContent('

Auto-created page

') + const title = await autoCreatedPage.locator('h1').textContent() + expect(title).toBe('Auto-created page') + + await browser.close() + }, 60000) +})