Skip to content

Bind playwright execute page to Chrome's active tab - #333

Merged
dprevoznik merged 9 commits into
mainfrom
hypeship/playwright-active-tab
Aug 18, 2026
Merged

Bind playwright execute page to Chrome's active tab#333
dprevoznik merged 9 commits into
mainfrom
hypeship/playwright-active-tab

Conversation

@dprevoznik

@dprevoznik dprevoznik commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

browsers.playwright.execute injected page as context.pages()[0] — the oldest tab in the context — regardless of which tab was active. In any multi-tab session this bound page to the wrong tab, so calls like page.pdf(), page.title(), etc. operated on a different tab than the one the automation had just navigated to.

Change

  • server/runtime/playwright-daemon.ts: added resolveActivePage, which resolves the browser's actual active tab over CDP — Target.getTargets filtered to tab targets, matched on TargetInfo.embedderData.tabActive (exposed by the Chrome 152.0.7977.42 build this image now ships, per Upgrade Chromium to 152.0.7977.42 #340), then mapped to a Playwright Page via Target.autoAttachRelated. Every CDP session it opens is temporary and detached before returning, so the daemon stays stateless across requests. page is bound to that resolved tab directly; resolution failures surface like any other daemon error, with no ordering heuristic or fallback.
  • While scanning context.pages() for the matching tab, a page whose CDP session setup or Target.getTargetInfo call fails is skipped rather than aborting the whole search, so one crashed or closing tab can't block resolution of the real active tab.
  • server/openapi.yaml: documents the selection policy on /playwright/executepage is bound to an active tab reported by Chrome. In single-window sessions that's the foreground tab; with multiple browser windows open, Chrome reports one active tab per window and the selected window is unspecified. context.pages() remains the explicit escape hatch.
  • server/e2e/e2e_playwright_test.go: extends the existing TestPlaywrightExecuteAPI (same container/warm daemon connection, no second container) to open a second tab via a data: URL and assert page binds to it, then bring the original tab — selected by URL rather than context.pages()[0] — back to the foreground and assert binding follows focus, not tab-creation order.

Scope

Depends on this image's Chrome 152.0.7977.42 exposing TargetInfo.embedderData.tabActive; single-tab sessions are unaffected. Cross-context foreground-tab resolution is out of scope — resolveActivePage searches the same context (contexts()[0]) already exposed to user code, matching this endpoint's existing single-context design rather than introducing a new limitation.

Testing

  • go vet ./e2e/... passes.
  • esbuild bundles the daemon cleanly.
  • TestPlaywrightExecuteAPI (extended) passes in CI against the image's real Chrome 152 build, covering both the new-tab and refocus cases; requires docker, skips otherwise.

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requesting changes on two maintainability issues: the new default relies on an undocumented page-ordering contract and the regression test duplicates a full container setup for a one-line selection policy.

Comment thread server/runtime/playwright-daemon.ts Outdated
// 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BrowserContext.pages() only documents that it returns all open pages; it does not guarantee creation order. This makes Playwright's current internal Set insertion order part of our public page contract, while the comment also conflates newest with current/active (open B, bring A forward, and this still selects B). Can we define the selection policy in the OpenAPI contract and either own the intended state or describe this precisely as a last-discovered-page heuristic? We should not ship the behavior change while leaving callers to infer the invariant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the ordering-based heuristic with a CDP lookup of the browser's real foreground tab (Target.getTargets + tabActive, mapped to a Playwright Page via Target.autoAttachRelated). This is now only a fallback, used when the underlying Chrome build doesn't expose that signal — documented both in code (see resolveActivePage) and in the OpenAPI description for /playwright/execute, which now states the policy precisely: foreground tab when available, otherwise most-recently-opened, with context.pages() as the escape hatch for explicit selection.

Comment thread server/e2e/e2e_playwright_test.go Outdated
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds 59 lines and another Docker container to verify a one-line policy change, duplicating the setup already in TestPlaywrightExecuteAPI. Can we fold this scenario into that existing warm-daemon test (or extract a shared harness if isolation matters)? While simplifying it, use deterministic data: URLs and compare JSON200.Result directly rather than adding another network dependency and JSON-marshal/substring assertion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Folded this into TestPlaywrightExecuteAPI, reusing its container/warm daemon connection instead of spinning up a second one. Swapped the second tab's https://example.net navigation for a data: URL so the tab-binding check no longer needs its own outbound request, and replaced the JSON-marshal/Contains check with a direct require.Equal against JSON200.Result.

Comment thread server/runtime/playwright-daemon.ts
@dprevoznik

Copy link
Copy Markdown
Contributor Author

Fixed in 59c3e11: moved newBrowserCDPSession() inside the try so a session-creation failure falls back to the heuristic like every other failure path in resolveActivePage, instead of rejecting and failing the whole execute request.

dprevoznik and others added 4 commits August 18, 2026 17:08
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@dprevoznik
dprevoznik force-pushed the hypeship/playwright-active-tab branch from 59c3e11 to 5d9bab0 Compare August 18, 2026 17:10
}
}

throw new Error('foreground tab has no matching Playwright page');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing pages may not attach

High Severity

resolveActivePage fills relatedPageIds only from Target.attachedToTarget after Target.autoAttachRelated. CDP documents that command as monitoring related-target creation and reporting newly created related targets. For already-open tabs, the set can stay empty, so the loop never matches and execution fails with foreground tab has no matching Playwright page.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5d9bab0. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified this doesn't reproduce against the image's actual Chrome (152.0.7977.42): the e2e test added in this PR (TestPlaywrightExecuteAPI, https://github.com/kernel/kernel-images/actions/runs/32164219559/job/95800815565) explicitly covers a pre-existing related target — it opens a second tab, then several execute calls later brings the original tab (created well before the autoAttachRelated call in question, not a target created in that same request) back to the foreground and asserts the daemon binds to it. That assertion passed in CI. In practice, autoAttachRelated on this target's browser session fires attachedToTarget for already-open related targets at call time, not just future creations, so relatedPageIds is populated correctly for this case. Leaving as-is; will revisit if this surfaces on a real session.

@dprevoznik
dprevoznik requested a review from rgarcia August 18, 2026 18:12
// 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Active tab ignored across contexts

Medium Severity

resolveActivePage picks the browser-wide foreground tab via CDP, but only searches for a matching Playwright Page inside the single context from contexts()[0]. If that tab lives in another context, binding throws and /playwright/execute fails before user code runs, so callers cannot recover via browser or context.pages().

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f2de954. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as-is: contexts()[0] is the pre-existing selection this whole endpoint has always used for the 'context' variable exposed to user code — it predates this PR and applies equally to the empty-context newPage() path a few lines up. resolveActivePage searching only within that same context is consistent with what 'context' means for this daemon, not a new limitation this PR introduces. If the true foreground tab lives in a different browser context, the old heuristic would have silently bound 'page' to the wrong context's tab; this now fails loudly instead, which is arguably safer. Multi-context support for this endpoint would be a bigger, separate change to how 'context' itself is resolved.

@rgarcia rgarcia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed — the active-tab resolution direction makes sense, and the test covers both opening a new tab and refocusing an older one. two things to address:

  • server/openapi.yaml:1342 — replace the current page description with: “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.”

  • server/e2e/e2e_playwright_test.go:119 — this test relies on context.pages()[0] being the oldest page, which is the same undocumented ordering assumption this change removes. Select the known original page instead:

    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();

- 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 <noreply@anthropic.com>
@dprevoznik

Copy link
Copy Markdown
Contributor Author

Pushed 5195138 addressing both: reworded the openapi.yaml description to your exact multi-window wording, and swapped the e2e test's context.pages()[0] for a URL-based lookup (throws if the original page isn't found).

@masnwilliams
masnwilliams self-requested a review August 18, 2026 18:52

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6703f7f. Configure here.

Comment thread server/runtime/playwright-daemon.ts
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 <noreply@anthropic.com>
@dprevoznik

Copy link
Copy Markdown
Contributor Author

Both addressed and pushed (eb7424b, rebased on the latest main-merge): openapi.yaml now uses your exact multi-window wording, and the e2e test selects the original page by URL instead of context.pages()[0]. Also fixed BugBot's low-severity finding from the latest pass (a crashed/closing page could abort the whole foreground-tab search) and replied to its medium finding (cross-context foreground tab) explaining why it's a pre-existing constraint of this endpoint's single-context design rather than something this PR introduces. All CI checks pass; BugBot is clean (no new findings) on the current commit.

@dprevoznik dprevoznik changed the title Bind playwright execute page to the most recently opened tab Bind playwright execute page to Chrome's active tab Aug 18, 2026
@dprevoznik
dprevoznik merged commit c3520af into main Aug 18, 2026
11 checks passed
@dprevoznik
dprevoznik deleted the hypeship/playwright-active-tab branch August 18, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants