diff --git a/extension/src/background.ts b/extension/src/background.ts index 0276ddf..9f2db5d 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -273,7 +273,7 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise { if (!tab.id) throw new Error('Failed to create tab') logger.debug('Created tab:', tab.id, 'waiting for it to load...') await sleep(100) - const targetInfo = await attachTab(tab.id) + const { targetInfo } = await attachTab(tab.id) return { targetId: targetInfo.targetId } satisfies Protocol.Target.CreateTargetResponse } @@ -378,7 +378,12 @@ function onDebuggerDetach(source: chrome.debugger.Debuggee, reason: `${chrome.de } } -async function attachTab(tabId: number): Promise { +type AttachTabResult = { + targetInfo: Protocol.Target.TargetInfo + sessionId: string +} + +async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAttachedEvent?: boolean } = {}): Promise { const debuggee = { tabId } logger.debug('Attaching debugger to tab:', tabId) @@ -415,20 +420,22 @@ async function attachTab(tabId: number): Promise { return { tabs: newTabs, connectionState: 'connected', errorText: undefined } }) - sendMessage({ - method: 'forwardCDPEvent', - params: { - method: 'Target.attachedToTarget', + if (!skipAttachedEvent) { + sendMessage({ + method: 'forwardCDPEvent', params: { - sessionId, - targetInfo: { ...targetInfo, attached: true }, - waitingForDebugger: false, + method: 'Target.attachedToTarget', + params: { + sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, }, - }, - }) + }) + } - logger.debug('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId) - return targetInfo + logger.debug('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId, 'skipAttachedEvent:', skipAttachedEvent) + return { targetInfo, sessionId } } function detachTab(tabId: number, shouldDetachDebugger: boolean): void { @@ -630,14 +637,32 @@ async function ensureConnection(): Promise { } // Handle createInitialTab - create a new tab when Playwright connects and no tabs exist + // We use skipAttachedEvent: true because the relay's Target.setAutoAttach handler will send + // Target.attachedToTarget for all targets in connectedTargets. If we also sent it here, + // Playwright would receive a duplicate. + // + // This differs from the normal flow (user clicks extension icon) where: + // 1. Extension attaches and sends Target.attachedToTarget to existing Playwright clients + // 2. New Playwright clients that connect later get targets via Target.setAutoAttach + // + // But with createInitialTab, the SAME client that triggered the create is waiting for + // Target.setAutoAttach - so we'd send the event twice to the same client. 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 } }) + const { targetInfo, sessionId } = await attachTab(tab.id, { skipAttachedEvent: true }) + logger.debug('Initial tab created and connected:', tab.id, 'sessionId:', sessionId) + sendMessage({ + id: message.id, + result: { + success: true, + tabId: tab.id, + sessionId, + targetInfo, + } + }) } else { throw new Error('Failed to create tab - no tab ID returned') } diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index b230ed8..c371c4a 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -193,6 +193,40 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127. }) } + // 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 { + if (!process.env.PLAYWRITER_AUTO_ENABLE) { + return + } + if (!extensionWs) { + return + } + if (connectedTargets.size > 0) { + return + } + + try { + logger?.log(chalk.blue('Auto-creating initial tab for Playwright client')) + const result = await sendToExtension({ 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, { + sessionId: result.sessionId, + targetId: result.targetInfo.targetId, + targetInfo: result.targetInfo + }) + logger?.log(chalk.blue(`Auto-created tab, now have ${connectedTargets.size} targets`)) + } + } catch (e) { + logger?.error('Failed to auto-create initial tab:', e) + } + } + async function routeCdpCommand({ method, params, sessionId }: { method: string; params: any; sessionId?: string }) { switch (method) { case 'Browser.getVersion': { @@ -209,10 +243,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127. return {} } + // Target.setAutoAttach is a CDP command Playwright sends on first connection. + // We use it as the hook to auto-create an initial tab. If Playwright changes + // its initialization sequence in the future, this could be moved to a different command. case 'Target.setAutoAttach': { if (sessionId) { break } + await maybeAutoCreateInitialTab() return {} } @@ -371,30 +409,6 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127. // 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) (extension? ${!!extensionWs}) (${connectedTargets.size} pages)`)) - - // 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 ea2dad8..4d6bce4 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -2924,12 +2924,10 @@ describe('Auto-enable Tests', () => { // 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) + expect(pages.length).toBe(1) const autoCreatedPage = pages[0] expect(autoCreatedPage.url()).toBe('about:blank')