Skip to content
Merged
63 changes: 63 additions & 0 deletions server/e2e/e2e_playwright_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions server/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
60 changes: 58 additions & 2 deletions server/runtime/playwright-daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -131,6 +131,58 @@ async function ensureBrowserConnection(): Promise<Browser> {
}
}

// 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<Page> {
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<string>();
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');

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.

Comment thread
cursor[bot] marked this conversation as resolved.
} finally {
await root.detach().catch(() => {});
}
}

async function executeCode(request: ExecuteRequest, signal: AbortSignal): Promise<ExecuteResponse> {
const { id, code } = request;

Expand Down Expand Up @@ -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();

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.


const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const userFunction = new AsyncFunction('page', 'context', 'browser', jsCode);
Expand Down
Loading