Handle Playwright active tabs across contexts - #347
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ab34134. Configure here.
| require.Equal(t, http.StatusOK, crossContextRsp.StatusCode(), "cross-context request %d returned %s body=%s", i+1, crossContextRsp.Status(), string(crossContextRsp.Body)) | ||
| require.NotNil(t, crossContextRsp.JSON200) | ||
| require.True(t, crossContextRsp.JSON200.Success, "cross-context request %d failed", i+1) | ||
| require.Equal(t, true, crossContextRsp.JSON200.Result, "cross-context request %d injected a mismatched page and context", i+1) |
There was a problem hiding this comment.
Vacuous cross-context assertion
Medium Severity
The cross-context probe returns page.context() === context, which is always true because executeCode sets context from page.context(). With the new null-to-findLast fallback, this loop can pass without checking that the injected page is the foreground tab from the second context (for example via page.url()), unlike the same-context checks above.
Triggered by learned rule: E2E tests must reuse containers, use deterministic inputs, and avoid undocumented ordering
Reviewed by Cursor Bugbot for commit ab34134. Configure here.
rgarcia
left a comment
There was a problem hiding this comment.
reviewed incrementally against CUS-580 (the #333 hard-fail regression). overall direction is right: cross-context resolution, retry-once, and fallback all match what the ticket asked for, and the e2e genuinely fails against #333-as-shipped via its Success assertions. a few things worth a look before ship:
test gaps
server/e2e/e2e_playwright_test.go:170—page.context() === contextcan never be false: executeCode derivescontextfrompage.context()(playwright-daemon.ts:263), so this passes no matter which page gets injected (bugbot flagged this too). assert page identity instead — e.g.return page.url()and require it containssecond-context.server/e2e/e2e_playwright_test.go:146-171— even a url assertion is masked by the fallback today:secondPageis also the newest page, sofindLastreturns it too whenever resolution silently misses. to pin the mechanism: open a third page in context 1 aftersecondPage(making it newest), foregroundsecondPage, then assert the url — correct resolution →data:url, fallback →about:blank. that's the assertion distinguishing "resolution won" from "fallback bailed us out", and it guards exactly the silent-wrong-tab mode.- note: cus-580's intermittent variant isn't exercised — with one window / two contexts there's a single deterministic
tabActivetarget; the multi-window lottery (one active tab per window, unspecified order) needs 2 windows. fine not to chase in ci, just acknowledging. - nit:
require.Equal(t, true, ...)→require.True(...)
daemon
server/runtime/playwright-daemon.ts:211— total resolution failure now degrades silently to newest-page binding. the fallback itself matches the cus-580 proposal, but a future chrome droppingembedderData.tabActivewould be invisible. worth aconsole.error('[playwright-daemon] active-tab resolution failed; falling back')before returning null (the daemon logs this way elsewhere).server/runtime/playwright-daemon.ts:192-211— retry/fallback is hard to follow: magic2, two bare catches whose meanings differ (inner = skip this tab target, outer = re-snapshot and retry the attempt) but aren't stated. behavior is defensible; structure needs a comment pass or the extraction below.server/runtime/playwright-daemon.ts:150-189— session churn: each execute burns1 + Wbrowser-level cdp sessions (activeTabTargetIds, then one per active tab) plus up-to-P throwaway per-page sessions, ×2 attempts worst case. one root session can serve the whole pass, and page→targetId is immutable so it memoizes cleanly in aWeakMap.- question: cus-580 also asked to skip resolution entirely for ops that never touch
page(listPagesetc.). this pr answers that only via the fallback — every call still pays full cdp resolution. intentional?
suggested shape
same semantics, cheaper and (we think) easier to follow — the core idea is an explicit join between playwright's page registry and chrome's tab strip, keyed by cdp page-target id:
═══ MODULE STATE ══════════════════════════════════════════════
targetIdMemo : WeakMap<Page, string> # Page → immutable cdp page-target id;
# WeakMap lets closed Pages gc
═══ PER EXECUTE CALL ══════════════════════════════════════════
execute(code):
contexts = browserInstance.contexts()
defaultContext = contexts[0] ?? await browserInstance.newContext()
pages = contexts.flatMap(c => c.pages()) # snapshot, ALL contexts
page =
resolveActivePage(browserInstance) # the join ↓
?? pages.findLast(p => !p.isClosed()) # fallback 1: newest open page
?? await defaultContext.newPage() # fallback 2: blank page
context = page.context() # derived — pair cannot mismatch
resolveActivePage(browser) → Page | null:
root = await browser.newBrowserCDPSession() # ONE session for the whole pass
try:
for attempt in 1..2: # focus can shift mid-join; retry
page = findActivePage(browser, root).catch(() => null) # re-snapshots both sides
if page: return page
console.error('[playwright-daemon] active-tab resolution failed; falling back')
return null
finally:
detach(root)
findActivePage(browser, root) → Page | null:
# joins two views by cdp page-target id:
# LEFT — playwright's live registry (what user code must receive)
# RIGHT — chrome's tab strip (sole source of truth for foreground,
# embedderData.tabActive, one per window)
# null when the join finds nothing: no active tabs reported, active tabs'
# related pages closed/prerender-only/unmatched (focus shifted mid-scan),
# or zero open pages. callers treat null as "fall back".
pageByTargetId = {}
for page in browser.contexts().flatMap(c => c.pages()):
if page.isClosed(): continue
pageByTargetId[targetIdOf(page)] = page # LEFT side (memoized)
{targetInfos} = root.send('Target.getTargets', filter tabs)
for tab in targetInfos where embedderData.tabActive == true:
for id in pageTargetIdsForTab(root, tab.targetId):
if pageByTargetId[id]: # ← THE JOIN
return pageByTargetId[id]
return null
targetIdOf(page) → string:
if targetIdMemo.has(page): return cached # steady state: free
s = page.context().newCDPSession(page) # page-scoped channel over the SAME
# connection; getTargetInfo() w/o args
# = "who am i", only answerable while
# attached to that page
{targetInfo} = s.send('Target.getTargetInfo')
detach(s)
targetIdMemo.set(page, targetInfo.targetId)
return targetInfo.targetId
pageTargetIdsForTab(root, tabId) → ids[]:
# tab targets and page targets have DIFFERENT ids and chrome exposes no direct
# tab→page query. autoAttachRelated(tabId) is the only bridge: attaching fires
# Target.attachedToTarget naming each page target under the tab.
listen → collect ids where type=='page' && !subtype # skips prerender
root.send('Target.autoAttachRelated', { targetId: tabId })
stop listening; return ids
known trade-offs: first-ever call probes all P pages up front instead of early-exiting (worst case identical to today; steady state ≈ free); the WeakMap is technically cross-request state, though it caches an immutable string rather than a live session — #333's "no state between calls" comment was about sessions specifically.


Summary
Validation
go vet ./e2e/...devtoolsproxytemp-directory cleanup failure; the focused rerun passedNote
Medium Risk
Changes how Playwright scripts bind
pageandcontextin multi-window/multi-context sessions, which can alter which tab user code operates on. Fallback and retry reduce hard failures but make selection slightly less deterministic under races.Overview
Playwright execute now binds
pageto Chrome’s active tab across all browser contexts, not just the first context. Injectedcontextis the owner of that page.Active-tab lookup retries once if CDP targets change mid-resolution, then falls back to an existing open page instead of failing. Docs and e2e coverage assert
page/contextstay matched when a second context is foregrounded.Reviewed by Cursor Bugbot for commit ab34134. Bugbot is set up for automated code reviews on this repo. Configure here.