From eee7745ae5576754b9593eaefc5da6e7679aa1a8 Mon Sep 17 00:00:00 2001 From: dprevoznik <58714078+dprevoznik@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:55:20 +0000 Subject: [PATCH 1/6] Bind playwright execute `page` to the most recently opened tab The playwright execution daemon injected `page` as context.pages()[0], the oldest tab, regardless of which tab was active. In multi-tab sessions this bound `page` to the wrong tab, so calls like page.pdf() captured a different tab than the one just navigated to. Select the last entry in context.pages() (creation order) so `page` is the most recently opened tab, and add an e2e test covering the multi-tab case. Co-Authored-By: Claude Opus 4.8 --- server/e2e/e2e_playwright_test.go | 59 +++++++++++++++++++++++++++++ server/runtime/playwright-daemon.ts | 7 +++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index 2ad6dc39..8a745011 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -81,6 +81,65 @@ func TestPlaywrightExecuteAPI(t *testing.T) { t.Log("playwright execute API test passed") } +// TestPlaywrightExecuteBindsMostRecentTab verifies that when multiple tabs are +// open, the injected `page` is bound to the most recently opened tab rather than +// the oldest one. The daemon keeps a warm CDP connection, so sequential execute +// calls share the same context and tabs. +func TestPlaywrightExecuteBindsMostRecentTab(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + + client, err := c.APIClient() + require.NoError(t, err) + + // Navigate the initial tab, then open a second tab on a different URL. + setupReq := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + Code: ` + await page.goto('https://example.com'); + const second = await context.newPage(); + await second.goto('https://example.net'); + return context.pages().length; + `, + } + setupRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, setupReq) + require.NoError(t, err, "setup request error: %v", err) + require.Equal(t, http.StatusOK, setupRsp.StatusCode(), "unexpected status: %s body=%s", setupRsp.Status(), string(setupRsp.Body)) + require.NotNil(t, setupRsp.JSON200) + require.True(t, setupRsp.JSON200.Success, "expected setup success=true") + + // A fresh execute call must bind `page` to the newest tab (example.net), + // not the first-opened tab (example.com). + urlReq := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + Code: `return page.url();`, + } + urlRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, urlReq) + 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.NotNil(t, urlRsp.JSON200.Result) + + resultBytes, err := json.Marshal(urlRsp.JSON200.Result) + require.NoError(t, err, "failed to marshal result: %v", err) + resultStr := string(resultBytes) + t.Logf("injected page url=%s", resultStr) + require.Contains(t, resultStr, "example.net", "expected injected page to be the most recently opened tab") + + t.Log("playwright most-recent-tab binding test passed") +} + func TestPlaywrightExecuteTimeoutReturnsPromptlyAndRecovers(t *testing.T) { t.Parallel() diff --git a/server/runtime/playwright-daemon.ts b/server/runtime/playwright-daemon.ts index 017ff14a..70804e72 100644 --- a/server/runtime/playwright-daemon.ts +++ b/server/runtime/playwright-daemon.ts @@ -172,7 +172,12 @@ 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 most recently opened tab. context.pages() is ordered by + // creation, so the last entry is the newest tab — the one an automation has + // just navigated to (e.g. a tab opened via a link click or window.open). Using + // pages[0] bound `page` to the oldest tab, so calls like page.pdf() operated on + // the wrong tab whenever more than one was open. + const page = pages.length > 0 ? pages[pages.length - 1] : await context.newPage(); const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; const userFunction = new AsyncFunction('page', 'context', 'browser', jsCode); From c7cd73ea9eb9ae73a30788eb52912e78ab2ec948 Mon Sep 17 00:00:00 2001 From: dprevoznik <58714078+dprevoznik@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:09:04 +0000 Subject: [PATCH 2/6] Resolve playwright execute page via CDP active-tab signal Replace the most-recently-opened-tab heuristic with a CDP-based lookup of the browser's actual foreground tab (Target.getTargets + tabActive + Target.autoAttachRelated), falling back to the heuristic on Chrome builds that don't expose the signal. Documents the selection policy in the OpenAPI spec and folds the tab-binding regression test into the existing warm-daemon test using a deterministic data: URL. Co-Authored-By: Claude Sonnet 5 --- server/e2e/e2e_playwright_test.go | 69 +++++++++------------------- server/openapi.yaml | 5 +++ server/runtime/playwright-daemon.ts | 70 ++++++++++++++++++++++++++--- 3 files changed, 90 insertions(+), 54 deletions(-) diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index 8a745011..4de9b289 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -79,63 +79,38 @@ func TestPlaywrightExecuteAPI(t *testing.T) { require.Contains(t, resultStr, "Example Domain", "expected result to contain 'Example Domain'") t.Log("playwright execute API test passed") -} - -// TestPlaywrightExecuteBindsMostRecentTab verifies that when multiple tabs are -// open, the injected `page` is bound to the most recently opened tab rather than -// the oldest one. The daemon keeps a warm CDP connection, so sequential execute -// calls share the same context and tabs. -func TestPlaywrightExecuteBindsMostRecentTab(t *testing.T) { - t.Parallel() - - if _, err := exec.LookPath("docker"); err != nil { - t.Skipf("docker not available: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - c := NewTestContainer(t, headlessImage) - require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") - defer c.Stop(ctx) - require.NoError(t, c.WaitReady(ctx), "api not ready") - - client, err := c.APIClient() - require.NoError(t, err) - - // Navigate the initial tab, then open a second tab on a different URL. - setupReq := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + // Reuse the same container/warm daemon connection to verify tab-binding + // behavior: when multiple tabs are open, `page` must bind to the most + // recently opened tab rather than the oldest one (the test image's Chrome + // build doesn't expose the CDP active-tab signal used by resolveActivePage + // in playwright-daemon.ts, so this exercises the fallback heuristic). 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 most recently opened tab") + openSecondTabRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ Code: ` - await page.goto('https://example.com'); const second = await context.newPage(); - await second.goto('https://example.net'); + await second.goto('data:text/html,second-tab'); return context.pages().length; `, - } - setupRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, setupReq) - require.NoError(t, err, "setup request error: %v", err) - require.Equal(t, http.StatusOK, setupRsp.StatusCode(), "unexpected status: %s body=%s", setupRsp.Status(), string(setupRsp.Body)) - require.NotNil(t, setupRsp.JSON200) - require.True(t, setupRsp.JSON200.Success, "expected setup success=true") - - // A fresh execute call must bind `page` to the newest tab (example.net), - // not the first-opened tab (example.com). - urlReq := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + }) + 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, not the + // first-opened tab still sitting at example.com. + urlRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ Code: `return page.url();`, - } - urlRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, urlReq) + }) 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.NotNil(t, urlRsp.JSON200.Result) - - resultBytes, err := json.Marshal(urlRsp.JSON200.Result) - require.NoError(t, err, "failed to marshal result: %v", err) - resultStr := string(resultBytes) - t.Logf("injected page url=%s", resultStr) - require.Contains(t, resultStr, "example.net", "expected injected page to be the most recently opened tab") + require.Equal(t, "data:text/html,second-tab", urlRsp.JSON200.Result, "expected injected page to be the most recently opened tab") t.Log("playwright most-recent-tab binding test passed") } diff --git a/server/openapi.yaml b/server/openapi.yaml index 20dfdef9..25293a42 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 the browser's actual foreground tab where the underlying Chrome build + exposes that signal over CDP. On builds that don't expose it, 'page' falls back to the + most recently opened tab in the context — not necessarily the tab last brought to the + front. Use 'context.pages()' to select a specific tab explicitly. operationId: executePlaywrightCode x-telemetry-category: control requestBody: diff --git a/server/runtime/playwright-daemon.ts b/server/runtime/playwright-daemon.ts index 70804e72..dfa5fccc 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. Returns +// null when the signal isn't available (older Chrome) or no match is found, so +// callers can fall back to a heuristic. +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) return null; + + 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()) { + 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(() => {}); + } + } + + return null; + } catch { + // Target.getTargets/autoAttachRelated can fail or be unsupported on older + // Chrome; fall back to the heuristic rather than failing the request. + return null; + } finally { + await root.detach().catch(() => {}); + } +} + async function executeCode(request: ExecuteRequest, signal: AbortSignal): Promise { const { id, code } = request; @@ -172,12 +224,16 @@ 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(); - // Bind `page` to the most recently opened tab. context.pages() is ordered by - // creation, so the last entry is the newest tab — the one an automation has - // just navigated to (e.g. a tab opened via a link click or window.open). Using - // pages[0] bound `page` to the oldest tab, so calls like page.pdf() operated on - // the wrong tab whenever more than one was open. - const page = pages.length > 0 ? pages[pages.length - 1] : await context.newPage(); + // Bind `page` to the actual foreground tab (see resolveActivePage). On Chrome + // builds that don't expose that signal, fall back to the most recently opened + // tab: context.pages() is ordered by creation, so the last entry is the + // newest tab — the one an automation has just navigated to (e.g. via a link + // click or window.open). Using pages[0] bound `page` to the oldest tab, 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)) ?? pages[pages.length - 1] + : await context.newPage(); const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; const userFunction = new AsyncFunction('page', 'context', 'browser', jsCode); From ac9769b0988dfe6f0b79bb098783042e038c005e Mon Sep 17 00:00:00 2001 From: dprevoznik <58714078+dprevoznik@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:21:11 +0000 Subject: [PATCH 3/6] Fix resolveActivePage session-creation failure bypassing fallback Move newBrowserCDPSession() inside the try block so a failure there falls back to the heuristic like every other failure path, instead of rejecting and failing the whole execute request. Co-Authored-By: Claude Sonnet 5 --- server/runtime/playwright-daemon.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server/runtime/playwright-daemon.ts b/server/runtime/playwright-daemon.ts index dfa5fccc..5778afee 100644 --- a/server/runtime/playwright-daemon.ts +++ b/server/runtime/playwright-daemon.ts @@ -140,9 +140,11 @@ async function ensureBrowserConnection(): Promise { // null when the signal isn't available (older Chrome) or no match is found, so // callers can fall back to a heuristic. async function resolveActivePage(browser: Browser, context: BrowserContext): Promise { - const root = await browser.newBrowserCDPSession(); + let root: Awaited> | undefined; try { + root = await browser.newBrowserCDPSession(); + const { targetInfos } = await root.send('Target.getTargets', { filter: [{ type: 'tab', exclude: false }, { exclude: true }], }); @@ -175,11 +177,12 @@ async function resolveActivePage(browser: Browser, context: BrowserContext): Pro return null; } catch { - // Target.getTargets/autoAttachRelated can fail or be unsupported on older - // Chrome; fall back to the heuristic rather than failing the request. + // Session creation, or Target.getTargets/autoAttachRelated, can fail or be + // unsupported on older Chrome; fall back to the heuristic rather than + // failing the request. return null; } finally { - await root.detach().catch(() => {}); + await root?.detach().catch(() => {}); } } From 5d9bab026488d304f369c995a57be11eb109f489 Mon Sep 17 00:00:00 2001 From: dprevoznik <58714078+dprevoznik@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:10:31 +0000 Subject: [PATCH 4/6] Simplify page resolution: drop most-recently-opened fallback Now that the image's Chrome is upgraded to 152.0.7977.42, the CDP tabActive signal resolveActivePage relies on is always available, so the fallback heuristic and its Page|null plumbing are dead weight. resolveActivePage now returns the foreground Page directly and throws on failure like any other daemon error, instead of silently falling back. Extends the e2e test to also verify that bringing an older tab back to the foreground flips which page gets injected -- the case the old heuristic could never have handled. Co-Authored-By: Claude Sonnet 5 --- server/e2e/e2e_playwright_test.go | 45 ++++++++++++++++++++++------- server/openapi.yaml | 6 ++-- server/runtime/playwright-daemon.ts | 32 ++++++-------------- 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index 4de9b289..e484ea61 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -81,13 +81,12 @@ func TestPlaywrightExecuteAPI(t *testing.T) { t.Log("playwright execute API test passed") // Reuse the same container/warm daemon connection to verify tab-binding - // behavior: when multiple tabs are open, `page` must bind to the most - // recently opened tab rather than the oldest one (the test image's Chrome - // build doesn't expose the CDP active-tab signal used by resolveActivePage - // in playwright-daemon.ts, so this exercises the fallback heuristic). 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 most recently opened tab") + // 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(); @@ -101,8 +100,8 @@ func TestPlaywrightExecuteAPI(t *testing.T) { 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, not the - // first-opened tab still sitting at example.com. + // 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();`, }) @@ -110,9 +109,33 @@ func TestPlaywrightExecuteAPI(t *testing.T) { 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 most recently opened tab") + require.Equal(t, "data:text/html,second-tab", urlRsp.JSON200.Result, "expected injected page to be the foreground tab") - t.Log("playwright most-recent-tab binding test passed") + // Bringing the first (oldest) tab back to the foreground must flip which + // page gets injected. 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()[0]; + 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 25293a42..d4d501fb 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1339,10 +1339,8 @@ paths: 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 the browser's actual foreground tab where the underlying Chrome build - exposes that signal over CDP. On builds that don't expose it, 'page' falls back to the - most recently opened tab in the context — not necessarily the tab last brought to the - front. Use 'context.pages()' to select a specific tab explicitly. + 'page' is bound to the browser's actual foreground tab, resolved over CDP. Use + 'context.pages()' to select a different tab explicitly. operationId: executePlaywrightCode x-telemetry-category: control requestBody: diff --git a/server/runtime/playwright-daemon.ts b/server/runtime/playwright-daemon.ts index 5778afee..aba493fd 100644 --- a/server/runtime/playwright-daemon.ts +++ b/server/runtime/playwright-daemon.ts @@ -136,21 +136,17 @@ async function ensureBrowserConnection(): Promise { // 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. Returns -// null when the signal isn't available (older Chrome) or no match is found, so -// callers can fall back to a heuristic. -async function resolveActivePage(browser: Browser, context: BrowserContext): Promise { - let root: Awaited> | undefined; +// 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 { - root = await browser.newBrowserCDPSession(); - 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) return null; + if (!activeTab) throw new Error('no foreground tab reported by CDP'); const relatedPageIds = new Set(); root.on('Target.attachedToTarget', event => { @@ -175,14 +171,9 @@ async function resolveActivePage(browser: Browser, context: BrowserContext): Pro } } - return null; - } catch { - // Session creation, or Target.getTargets/autoAttachRelated, can fail or be - // unsupported on older Chrome; fall back to the heuristic rather than - // failing the request. - return null; + throw new Error('foreground tab has no matching Playwright page'); } finally { - await root?.detach().catch(() => {}); + await root.detach().catch(() => {}); } } @@ -227,16 +218,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(); - // Bind `page` to the actual foreground tab (see resolveActivePage). On Chrome - // builds that don't expose that signal, fall back to the most recently opened - // tab: context.pages() is ordered by creation, so the last entry is the - // newest tab — the one an automation has just navigated to (e.g. via a link - // click or window.open). Using pages[0] bound `page` to the oldest tab, so + // 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)) ?? pages[pages.length - 1] - : await context.newPage(); + 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); From 51951380aaa14b111aa224bf016aa365243cf0f9 Mon Sep 17 00:00:00 2001 From: dprevoznik <58714078+dprevoznik@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:42:07 +0000 Subject: [PATCH 5/6] Address review: multi-window OpenAPI wording, URL-based tab selection - server/openapi.yaml: describe 'page' as an active tab reported by Chrome, noting that with multiple browser windows Chrome reports one active tab per window and which window is selected is unspecified. - server/e2e/e2e_playwright_test.go: select the original tab by URL instead of context.pages()[0], which relied on the same undocumented ordering assumption this change removes. Co-Authored-By: Claude Sonnet 5 --- server/e2e/e2e_playwright_test.go | 14 ++++++++++---- server/openapi.yaml | 6 ++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index e484ea61..597c8118 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -111,12 +111,18 @@ func TestPlaywrightExecuteAPI(t *testing.T) { 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 first (oldest) tab back to the foreground must flip which - // page gets injected. Tab-creation order alone can't distinguish this case - // from the one above -- only the CDP `tabActive` signal can. + // 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()[0]; + 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(); `, diff --git a/server/openapi.yaml b/server/openapi.yaml index a8c69c72..25f07df0 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1339,8 +1339,10 @@ paths: 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 the browser's actual foreground tab, resolved over CDP. Use - 'context.pages()' to select a different tab explicitly. + '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: From eb7424bb8f13fd47be1ce0cad534308373e754bd Mon Sep 17 00:00:00 2001 From: dprevoznik <58714078+dprevoznik@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:00:03 +0000 Subject: [PATCH 6/6] Skip pages that fail CDP session setup while scanning for foreground tab A crashed or closing page can throw from newCDPSession/getTargetInfo, which previously aborted the whole search even when the real foreground tab was later in context.pages(). Catch per-page and continue instead. Co-Authored-By: Claude Sonnet 5 --- server/runtime/playwright-daemon.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/server/runtime/playwright-daemon.ts b/server/runtime/playwright-daemon.ts index aba493fd..2b5ff044 100644 --- a/server/runtime/playwright-daemon.ts +++ b/server/runtime/playwright-daemon.ts @@ -162,12 +162,18 @@ async function resolveActivePage(browser: Browser, context: BrowserContext): Pro }); for (const page of context.pages()) { - 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(() => {}); + 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; } }