diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index 2ad6dc39..597c8118 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -79,6 +79,69 @@ func TestPlaywrightExecuteAPI(t *testing.T) { require.Contains(t, resultStr, "Example Domain", "expected result to contain 'Example Domain'") t.Log("playwright execute API test passed") + + // Reuse the same container/warm daemon connection to verify tab-binding + // behavior: `page` must bind to the browser's actual foreground tab, not + // just the most recently opened one (resolveActivePage in + // playwright-daemon.ts, backed by the image's Chrome 150+ CDP `tabActive` + // signal). Uses a `data:` URL for the second tab so the assertion doesn't + // depend on a second outbound network request. + t.Log("verifying page binds to the foreground tab") + openSecondTabRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + Code: ` + const second = await context.newPage(); + await second.goto('data:text/html,second-tab'); + return context.pages().length; + `, + }) + require.NoError(t, err, "open second tab request error: %v", err) + require.Equal(t, http.StatusOK, openSecondTabRsp.StatusCode(), "unexpected status: %s body=%s", openSecondTabRsp.Status(), string(openSecondTabRsp.Body)) + require.NotNil(t, openSecondTabRsp.JSON200) + require.True(t, openSecondTabRsp.JSON200.Success, "expected open-second-tab success=true") + require.EqualValues(t, 2, openSecondTabRsp.JSON200.Result, "expected two open tabs") + + // A fresh execute call must bind `page` to the newest tab, which is also + // the foreground one right after opening it. + urlRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + Code: `return page.url();`, + }) + require.NoError(t, err, "url request error: %v", err) + require.Equal(t, http.StatusOK, urlRsp.StatusCode(), "unexpected status: %s body=%s", urlRsp.Status(), string(urlRsp.Body)) + require.NotNil(t, urlRsp.JSON200) + require.True(t, urlRsp.JSON200.Success, "expected url request success=true") + require.Equal(t, "data:text/html,second-tab", urlRsp.JSON200.Result, "expected injected page to be the foreground tab") + + // Bringing the original tab back to the foreground must flip which page + // gets injected. Select it by URL rather than context.pages()[0] -- relying + // on index/creation order would reintroduce the same undocumented ordering + // assumption this change removes. Tab-creation order alone can't + // distinguish this case from the one above -- only the CDP `tabActive` + // signal can. + bringToFrontRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + Code: ` + const first = context.pages().find(candidate => + candidate.url().includes('example.com') + ); + if (!first) throw new Error('original page not found'); + await first.bringToFront(); + return first.url(); + `, + }) + require.NoError(t, err, "bring-to-front request error: %v", err) + require.Equal(t, http.StatusOK, bringToFrontRsp.StatusCode(), "unexpected status: %s body=%s", bringToFrontRsp.Status(), string(bringToFrontRsp.Body)) + require.NotNil(t, bringToFrontRsp.JSON200) + require.True(t, bringToFrontRsp.JSON200.Success, "expected bring-to-front success=true") + + refocusedUrlRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + Code: `return page.url();`, + }) + require.NoError(t, err, "refocused url request error: %v", err) + require.Equal(t, http.StatusOK, refocusedUrlRsp.StatusCode(), "unexpected status: %s body=%s", refocusedUrlRsp.Status(), string(refocusedUrlRsp.Body)) + require.NotNil(t, refocusedUrlRsp.JSON200) + require.True(t, refocusedUrlRsp.JSON200.Success, "expected refocused url request success=true") + require.Contains(t, refocusedUrlRsp.JSON200.Result, "example.com", "expected injected page to follow foreground focus, not tab-creation order") + + t.Log("playwright foreground-tab binding test passed") } func TestPlaywrightExecuteTimeoutReturnsPromptlyAndRecovers(t *testing.T) { diff --git a/server/openapi.yaml b/server/openapi.yaml index 3f186d4f..25f07df0 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1338,6 +1338,11 @@ paths: Execute arbitrary Playwright code in a fresh execution context against the browser running on localhost:9222. The code has access to 'page', 'context', and 'browser' variables. The result of the code execution is returned in the response. + + 'page' is bound to an active tab reported by Chrome. In single-window sessions, this is + the foreground tab. When multiple browser windows are open, Chrome reports one active tab + per window and the selected window is unspecified. Use 'context.pages()' to select a page + explicitly. operationId: executePlaywrightCode x-telemetry-category: control requestBody: diff --git a/server/runtime/playwright-daemon.ts b/server/runtime/playwright-daemon.ts index 017ff14a..2b5ff044 100644 --- a/server/runtime/playwright-daemon.ts +++ b/server/runtime/playwright-daemon.ts @@ -12,7 +12,7 @@ import { createServer, Socket } from 'net'; import { unlinkSync, existsSync } from 'fs'; import { transform } from 'esbuild'; -import { chromium as chromiumPW, Browser } from 'playwright-core'; +import { chromium as chromiumPW, Browser, BrowserContext, Page } from 'playwright-core'; import { chromium as chromiumPR } from 'patchright'; const SOCKET_PATH = process.env.PLAYWRIGHT_DAEMON_SOCKET || '/tmp/playwright-daemon.sock'; @@ -131,6 +131,58 @@ async function ensureBrowserConnection(): Promise { } } +// Resolves the browser's actual foreground tab via CDP rather than guessing from +// tab-creation order. Chrome 150+ populates `TargetInfo.embedderData.tabActive` +// on `tab` targets from the real tab strip state; the Playwright `Page` for that +// tab is found by relating the tab target to its page target with +// `Target.autoAttachRelated`. Every session used here is temporary and detached +// before returning, so this adds no cross-request state to the daemon. +async function resolveActivePage(browser: Browser, context: BrowserContext): Promise { + const root = await browser.newBrowserCDPSession(); + + try { + const { targetInfos } = await root.send('Target.getTargets', { + filter: [{ type: 'tab', exclude: false }, { exclude: true }], + }); + + const activeTab = targetInfos.find(target => (target.embedderData as any)?.tabActive === true); + if (!activeTab) throw new Error('no foreground tab reported by CDP'); + + const relatedPageIds = new Set(); + root.on('Target.attachedToTarget', event => { + if (event.targetInfo.type === 'page' && !event.targetInfo.subtype) { + relatedPageIds.add(event.targetInfo.targetId); + } + }); + + await root.send('Target.autoAttachRelated', { + targetId: activeTab.targetId, + waitForDebuggerOnStart: false, + filter: [{ type: 'page', exclude: false }, { exclude: true }], + }); + + for (const page of context.pages()) { + try { + const session = await context.newCDPSession(page); + try { + const { targetInfo } = await session.send('Target.getTargetInfo'); + if (relatedPageIds.has(targetInfo.targetId)) return page; + } finally { + await session.detach().catch(() => {}); + } + } catch { + // A crashed or closing page can fail CDP session setup/queries; skip it + // rather than aborting the search for the real foreground tab. + continue; + } + } + + throw new Error('foreground tab has no matching Playwright page'); + } finally { + await root.detach().catch(() => {}); + } +} + async function executeCode(request: ExecuteRequest, signal: AbortSignal): Promise { const { id, code } = request; @@ -172,7 +224,11 @@ async function executeCode(request: ExecuteRequest, signal: AbortSignal): Promis const contexts = browserInstance.contexts(); const context = contexts.length > 0 ? contexts[0] : await browserInstance.newContext(); const pages = context.pages(); - const page = pages.length > 0 ? pages[0] : await context.newPage(); + // Bind `page` to the actual foreground tab (see resolveActivePage). Using + // pages[0] bound `page` to the oldest tab regardless of which was active, so + // calls like page.pdf() operated on the wrong tab whenever more than one was + // open. + const page = pages.length > 0 ? await resolveActivePage(browserInstance, context) : await context.newPage(); const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; const userFunction = new AsyncFunction('page', 'context', 'browser', jsCode);