Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions server/e2e/e2e_playwright_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,33 @@ func TestPlaywrightExecuteAPI(t *testing.T) {
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")

t.Log("verifying page resolution across browser contexts")
openSecondContextRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{
Code: `
const secondContext = await browser.newContext();
const secondPage = await secondContext.newPage();
await secondPage.goto('data:text/html,second-context');
await secondPage.bringToFront();
return browser.contexts().length;
`,
})
require.NoError(t, err, "open second context request error: %v", err)
require.Equal(t, http.StatusOK, openSecondContextRsp.StatusCode(), "unexpected status: %s body=%s", openSecondContextRsp.Status(), string(openSecondContextRsp.Body))
require.NotNil(t, openSecondContextRsp.JSON200)
require.True(t, openSecondContextRsp.JSON200.Success, "expected open-second-context success=true")
require.EqualValues(t, 2, openSecondContextRsp.JSON200.Result, "expected two browser contexts")

for i := 0; i < 30; i++ {
crossContextRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{
Code: `return page.context() === context && browser.contexts().length === 2;`,
})
require.NoError(t, err, "cross-context request %d error: %v", i+1, err)
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)
Comment thread
ehfeng marked this conversation as resolved.
Outdated
}
}

func TestPlaywrightExecuteTimeoutReturnsPromptlyAndRecovers(t *testing.T) {
Expand Down
5 changes: 3 additions & 2 deletions server/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1366,8 +1366,9 @@ paths:

'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.
per window and the selected window is unspecified. 'context' is the BrowserContext that owns
the selected page. If active-tab resolution races with a target change, execution falls back
to an existing page. Use 'browser.contexts()' to select a context or page explicitly.
operationId: executePlaywrightCode
x-telemetry-category: control
requestBody:
Expand Down
64 changes: 48 additions & 16 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, BrowserContext, Page } from 'playwright-core';
import { chromium as chromiumPW, Browser, 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,23 +131,26 @@ 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> {
async function activeTabTargetIds(browser: Browser): Promise<string[]> {
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');
return targetInfos
.filter(target => (target.embedderData as any)?.tabActive === true)
.map(target => target.targetId);
} finally {
await root.detach().catch(() => {});
}
}

async function pageForTabTarget(browser: Browser, targetId: string, pages: Page[]): Promise<Page | null> {
const root = await browser.newBrowserCDPSession();

try {
const relatedPageIds = new Set<string>();
root.on('Target.attachedToTarget', event => {
if (event.targetInfo.type === 'page' && !event.targetInfo.subtype) {
Expand All @@ -156,13 +159,14 @@ async function resolveActivePage(browser: Browser, context: BrowserContext): Pro
});

await root.send('Target.autoAttachRelated', {
targetId: activeTab.targetId,
targetId,
waitForDebuggerOnStart: false,
filter: [{ type: 'page', exclude: false }, { exclude: true }],
});

for (const page of context.pages()) {
for (const page of pages) {
try {
const context = page.context();
const session = await context.newCDPSession(page);
try {
const { targetInfo } = await session.send('Target.getTargetInfo');
Expand All @@ -177,12 +181,36 @@ async function resolveActivePage(browser: Browser, context: BrowserContext): Pro
}
}

throw new Error('foreground tab has no matching Playwright page');
return null;
} finally {
await root.detach().catch(() => {});
}
}

// Chrome reports one active tab per window. Try every reported target against
// every Playwright context, then retry once in case focus changed mid-lookup.
async function resolveActivePage(browser: Browser): Promise<Page | null> {
for (let attempt = 0; attempt < 2; attempt++) {
try {
const pages = browser
.contexts()
.flatMap(context => context.pages())
.filter(page => !page.isClosed());

for (const targetId of await activeTabTargetIds(browser)) {
try {
const page = await pageForTabTarget(browser, targetId, pages);
if (page) return page;
} catch {}
}
} catch {
// Fall back after the retry if Chrome changes targets during resolution.
}
}

return null;
}

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

Expand Down Expand Up @@ -222,13 +250,17 @@ 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 defaultContext = contexts.length > 0 ? contexts[0] : await browserInstance.newContext();
const pages = contexts.flatMap(context => context.pages());
// 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 page =
(pages.length > 0 ? await resolveActivePage(browserInstance) : null) ??
pages.findLast(candidate => !candidate.isClosed()) ??
(await defaultContext.newPage());
const context = page.context();

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