Fix auto-enable to work without delay by skipping duplicate Target.attachedToTarget

Move auto-create logic from onOpen to Target.setAutoAttach handler so it runs
synchronously in the CDP command flow. Add skipAttachedEvent option to extension's
attachTab to prevent sending Target.attachedToTarget when the relay will send it
via the Target.setAutoAttach response handler.
This commit is contained in:
Tommy D. Rossi
2026-01-01 02:53:10 +01:00
parent 002f72af2c
commit ef6ab59351
3 changed files with 80 additions and 43 deletions
+41 -16
View File
@@ -273,7 +273,7 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise<any> {
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<Protocol.Target.TargetInfo> {
type AttachTabResult = {
targetInfo: Protocol.Target.TargetInfo
sessionId: string
}
async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAttachedEvent?: boolean } = {}): Promise<AttachTabResult> {
const debuggee = { tabId }
logger.debug('Attaching debugger to tab:', tabId)
@@ -415,20 +420,22 @@ async function attachTab(tabId: number): Promise<Protocol.Target.TargetInfo> {
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<void> {
}
// 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')
}
+38 -24
View File
@@ -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<void> {
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) {
+1 -3
View File
@@ -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')