fix(browser): require exact Playwright target identity (#103177)

This commit is contained in:
Peter Steinberger
2026-07-10 02:12:53 +01:00
committed by GitHub
parent a5d70e114c
commit aef945f847
4 changed files with 286 additions and 418 deletions

View File

@@ -616,7 +616,6 @@ describe("pw-session createPageViaPlaywright navigation guard", () => {
const page = await getPageForTargetId({
cdpUrl: "http://127.0.0.1:18792",
targetId: "MISSING_TARGET",
});
pageGoto.mockImplementationOnce(async () => {

View File

@@ -1,299 +0,0 @@
// Browser tests cover pw session.get page for targetid.extension fallback plugin behavior.
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "./chrome.js";
import {
closePlaywrightBrowserConnection,
getPageForTargetId,
listPagesViaPlaywright,
setCdpConnectRetryDelayMsForTests,
} from "./pw-session.js";
const fetchWithSsrFGuardSpy = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (...args: Parameters<typeof actual.fetchWithSsrFGuard>) => {
fetchWithSsrFGuardSpy(...args);
return actual.fetchWithSsrFGuard(...args);
},
};
});
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
type MockPageSpec = {
targetId?: string;
url?: string;
title?: string;
};
type BrowserMockBundle = {
browser: import("playwright-core").Browser;
browserClose: ReturnType<typeof vi.fn>;
pages: import("playwright-core").Page[];
};
type FetchInitWithDispatcher = RequestInit & { dispatcher?: unknown };
function requireFetchCall(fetchSpy: {
mock: { calls: Parameters<typeof fetch>[] };
}): Parameters<typeof fetch> {
const [call] = fetchSpy.mock.calls;
if (!call) {
throw new Error("expected fallback fetch call");
}
return call;
}
function requireFetchInit(init: Parameters<typeof fetch>[1]): FetchInitWithDispatcher {
if (!init || typeof init !== "object") {
throw new Error("expected fallback fetch init");
}
return init as FetchInitWithDispatcher;
}
function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle {
const browserClose = vi.fn(async () => {});
const targetIdByPage = new Map<import("playwright-core").Page, string | undefined>();
const pageObjects = pages.map((spec, index) => {
const page = {
on: vi.fn(),
context: () => context,
title: vi.fn(async () => spec.title ?? spec.targetId ?? `page-${index + 1}`),
url: vi.fn(() => spec.url ?? `https://page-${index + 1}.example`),
} as unknown as import("playwright-core").Page;
targetIdByPage.set(page, spec.targetId);
return page;
});
const context: import("playwright-core").BrowserContext = {
pages: () => pageObjects,
on: vi.fn(),
newCDPSession: vi.fn(async (page: import("playwright-core").Page) => ({
send: vi.fn(async (method: string) =>
method === "Target.getTargetInfo"
? { targetInfo: { targetId: targetIdByPage.get(page) } }
: {},
),
detach: vi.fn(async () => {}),
})),
} as unknown as import("playwright-core").BrowserContext;
const browser = {
contexts: () => [context],
on: vi.fn(),
off: vi.fn(),
close: browserClose,
} as unknown as import("playwright-core").Browser;
return { browser, browserClose, pages: pageObjects };
}
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketUrlSpy.mockReset();
fetchWithSsrFGuardSpy.mockClear();
setCdpConnectRetryDelayMsForTests();
await closePlaywrightBrowserConnection().catch(() => {});
});
function createExtensionFallbackBrowserHarness(options?: {
urls?: string[];
newCDPSessionError?: string;
}) {
const pageOn = vi.fn();
const contextOn = vi.fn();
const browserOn = vi.fn();
const browserClose = vi.fn(async () => {});
const newCDPSession = vi.fn(async () => {
throw new Error(options?.newCDPSessionError ?? "Not allowed");
});
const context = {
pages: () => [],
on: contextOn,
newCDPSession,
} as unknown as import("playwright-core").BrowserContext;
const pages = (options?.urls ?? [undefined]).map(
(url) =>
Object.assign(
{ on: pageOn, context: () => context },
url ? { url: () => url } : {},
) as unknown as import("playwright-core").Page,
);
(context as unknown as { pages: () => unknown[] }).pages = () => pages;
const browser = {
contexts: () => [context],
on: browserOn,
close: browserClose,
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
return { browserClose, newCDPSession, pages };
}
describe("pw-session getPageForTargetId", () => {
it("falls back to the only page when Playwright cannot resolve target ids", async () => {
const { browserClose, pages } = createExtensionFallbackBrowserHarness();
const [page] = pages;
const resolved = await getPageForTargetId({
cdpUrl: "http://127.0.0.1:18792",
targetId: "NOT_A_TAB",
});
expect(resolved).toBe(page);
await closePlaywrightBrowserConnection();
expect(browserClose).toHaveBeenCalled();
});
it("uses the shared HTTP-base normalization when falling back to /json/list for direct WebSocket CDP URLs", async () => {
const [, pageB] = createExtensionFallbackBrowserHarness({
urls: ["https://alpha.example", "https://beta.example"],
}).pages;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{ id: "TARGET_A", url: "https://alpha.example" },
{ id: "TARGET_B", url: "https://beta.example" },
]),
{ headers: { "content-type": "application/json" } },
),
);
try {
const resolved = await getPageForTargetId({
cdpUrl: "ws://127.0.0.1:18792/devtools/browser/SESSION?token=abc",
targetId: "TARGET_B",
});
expect(resolved).toBe(pageB);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [fetchUrl, fetchInitOptions] = requireFetchCall(fetchSpy);
expect(fetchUrl).toBe("http://127.0.0.1:18792/json/list?token=abc");
const fetchInit = requireFetchInit(fetchInitOptions);
expect(fetchInit.headers).toEqual({});
expect(fetchInit.redirect).toBe("manual");
expect(fetchInit.signal).toBeInstanceOf(AbortSignal);
expect(fetchInit.dispatcher).toBeUndefined();
} finally {
fetchSpy.mockRestore();
}
});
it("resolves pages from /json/list when page CDP probing fails", async () => {
const { newCDPSession, pages } = createExtensionFallbackBrowserHarness({
urls: ["https://alpha.example", "https://beta.example"],
newCDPSessionError: "Target.attachToBrowserTarget: Not allowed",
});
const [, pageB] = pages;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{ id: "TARGET_A", url: "https://alpha.example" },
{ id: "TARGET_B", url: "https://beta.example" },
]),
{ headers: { "content-type": "application/json" } },
),
);
try {
const resolved = await getPageForTargetId({
cdpUrl: "http://127.0.0.1:19993",
targetId: "TARGET_B",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
allowRfc2544BenchmarkRange: true,
},
});
expect(resolved).toBe(pageB);
expect(newCDPSession).toHaveBeenCalled();
expect(fetchWithSsrFGuardSpy).toHaveBeenCalledTimes(1);
const policy = fetchWithSsrFGuardSpy.mock.calls[0]?.[0]?.policy as SsrFPolicy | undefined;
expect(policy?.allowRfc2544BenchmarkRange).toBe(true);
expect(policy?.hostnameAllowlist).toEqual(["127.0.0.1"]);
} finally {
fetchSpy.mockRestore();
}
});
it("evicts a stale cached page-less browser once and succeeds on a fresh reconnect", async () => {
const stale = makeBrowser([]);
const fresh = makeBrowser([{ targetId: "TARGET_OK", url: "https://fresh.example" }]);
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
const resolved = await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9222" });
expect(resolved).toBe(fresh.pages[0]);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(stale.browserClose).toHaveBeenCalledTimes(1);
});
it("evicts a stale cached tab-selection miss once and succeeds on a fresh reconnect", async () => {
const stale = makeBrowser([
{ targetId: "TARGET_A", url: "https://alpha.example" },
{ targetId: "TARGET_C", url: "https://charlie.example" },
]);
const fresh = makeBrowser([
{ targetId: "TARGET_A", url: "https://alpha.example" },
{ targetId: "TARGET_B", url: "https://beta.example" },
]);
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" });
const resolved = await getPageForTargetId({
cdpUrl: "http://127.0.0.1:9333",
targetId: "TARGET_B",
});
expect(resolved).toBe(fresh.pages[1]);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(stale.browserClose).toHaveBeenCalledTimes(1);
});
it("fails after a single reconnect when the refreshed browser is still page-less", async () => {
const stale = makeBrowser([]);
const stillBroken = makeBrowser([]);
connectOverCdpSpy
.mockResolvedValueOnce(stale.browser)
.mockResolvedValueOnce(stillBroken.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9444" });
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9444" })).rejects.toThrow(
"No pages available in the connected browser.",
);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(stale.browserClose).toHaveBeenCalledTimes(1);
});
it("does not add an extra top-level retry for non-recoverable connect failures", async () => {
setCdpConnectRetryDelayMsForTests(0);
connectOverCdpSpy.mockRejectedValue(new Error("connectOverCDP exploded"));
getChromeWebSocketUrlSpy.mockResolvedValue(null);
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9555" })).rejects.toThrow(
"connectOverCDP exploded",
);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,275 @@
// Browser tests cover exact Playwright page selection by CDP target id.
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as chromeModule from "./chrome.js";
import { BrowserTabNotFoundError } from "./errors.js";
import {
closePageByTargetIdViaPlaywright,
closePlaywrightBrowserConnection,
focusPageByTargetIdViaPlaywright,
getPageForTargetId,
listPagesViaPlaywright,
setCdpConnectRetryDelayMsForTests,
} from "./pw-session.js";
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
type MockPageSpec = {
targetId?: string;
url?: string;
title?: string;
targetLookupError?: string;
};
type BrowserMockBundle = {
browser: import("playwright-core").Browser;
browserClose: ReturnType<typeof vi.fn>;
pages: import("playwright-core").Page[];
pageActions: Array<{
bringToFront: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}>;
};
function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle {
const browserClose = vi.fn(async () => {});
const specByPage = new Map<import("playwright-core").Page, MockPageSpec>();
const pageActions = pages.map(() => ({
bringToFront: vi.fn(async () => {}),
close: vi.fn(async () => {}),
}));
const pageObjects = pages.map((spec, index) => {
const actions = pageActions[index]!;
const page = {
on: vi.fn(),
context: () => context,
title: vi.fn(async () => spec.title ?? spec.targetId ?? `page-${index + 1}`),
url: vi.fn(() => spec.url ?? `https://page-${index + 1}.example`),
bringToFront: actions.bringToFront,
close: actions.close,
} as unknown as import("playwright-core").Page;
specByPage.set(page, spec);
return page;
});
const context: import("playwright-core").BrowserContext = {
pages: () => pageObjects,
on: vi.fn(),
newCDPSession: vi.fn(async (page: import("playwright-core").Page) => {
const spec = specByPage.get(page);
return {
send: vi.fn(async (method: string) => {
if (method !== "Target.getTargetInfo") {
return {};
}
if (spec?.targetLookupError) {
throw new Error(spec.targetLookupError);
}
return { targetInfo: { targetId: spec?.targetId } };
}),
detach: vi.fn(async () => {}),
};
}),
} as unknown as import("playwright-core").BrowserContext;
const browser = {
contexts: () => [context],
on: vi.fn(),
off: vi.fn(),
close: browserClose,
} as unknown as import("playwright-core").Browser;
return { browser, browserClose, pages: pageObjects, pageActions };
}
function installBrowser(pages: MockPageSpec[]): BrowserMockBundle {
const bundle = makeBrowser(pages);
connectOverCdpSpy.mockResolvedValue(bundle.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
return bundle;
}
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketUrlSpy.mockReset();
setCdpConnectRetryDelayMsForTests();
await closePlaywrightBrowserConnection().catch(() => {});
});
describe("pw-session getPageForTargetId", () => {
it("keeps no-target selection when Playwright cannot resolve target ids", async () => {
const { pages } = installBrowser([{ targetLookupError: "Not allowed" }]);
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:18792" })).resolves.toBe(pages[0]);
});
it("rejects an explicit target when the sole page cannot expose its target id", async () => {
const { pageActions } = installBrowser([{ targetLookupError: "Not allowed" }]);
await expect(
getPageForTargetId({
cdpUrl: "http://127.0.0.1:18792",
targetId: "NOT_A_TAB",
}),
).rejects.toBeInstanceOf(BrowserTabNotFoundError);
expect(pageActions[0]?.close).not.toHaveBeenCalled();
expect(pageActions[0]?.bringToFront).not.toHaveBeenCalled();
});
it("does not infer target identity from duplicate URL ordering", async () => {
installBrowser([
{ url: "https://same.example", targetLookupError: "Not allowed" },
{ url: "https://same.example", targetLookupError: "Not allowed" },
]);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{ id: "TARGET_B", url: "https://same.example" },
{ id: "TARGET_A", url: "https://same.example" },
]),
{ headers: { "content-type": "application/json" } },
),
);
try {
await expect(
getPageForTargetId({
cdpUrl: "http://127.0.0.1:18792",
targetId: "TARGET_B",
}),
).rejects.toBeInstanceOf(BrowserTabNotFoundError);
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("matches duplicate-URL pages only by their exact CDP target id", async () => {
const { pages } = installBrowser([
{ targetId: "TARGET_A", url: "https://same.example" },
{ targetId: "TARGET_B", url: "https://same.example" },
]);
const resolved = await getPageForTargetId({
cdpUrl: "http://127.0.0.1:18792",
targetId: "TARGET_B",
});
expect(resolved).toBe(pages[1]);
});
it("focuses and closes only the exact target when URLs are identical", async () => {
const { pageActions } = installBrowser([
{ targetId: "TARGET_A", url: "https://same.example" },
{ targetId: "TARGET_B", url: "https://same.example" },
]);
const [pageA, pageB] = pageActions;
await focusPageByTargetIdViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "TARGET_B",
});
await closePageByTargetIdViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "TARGET_B",
});
expect(pageA?.bringToFront).not.toHaveBeenCalled();
expect(pageA?.close).not.toHaveBeenCalled();
expect(pageB?.bringToFront).toHaveBeenCalledTimes(1);
expect(pageB?.close).toHaveBeenCalledTimes(1);
});
it("does not focus or close a sole unrelated page for a stale target", async () => {
const { pageActions } = installBrowser([{ targetId: "TARGET_A" }]);
const [page] = pageActions;
await expect(
focusPageByTargetIdViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "STALE_TARGET",
}),
).rejects.toBeInstanceOf(BrowserTabNotFoundError);
await expect(
closePageByTargetIdViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "STALE_TARGET",
}),
).rejects.toBeInstanceOf(BrowserTabNotFoundError);
expect(page?.bringToFront).not.toHaveBeenCalled();
expect(page?.close).not.toHaveBeenCalled();
});
it("evicts a stale cached page-less browser once and succeeds on a fresh reconnect", async () => {
const stale = makeBrowser([]);
const fresh = makeBrowser([{ targetId: "TARGET_OK", url: "https://fresh.example" }]);
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
const resolved = await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9222" });
expect(resolved).toBe(fresh.pages[0]);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(stale.browserClose).toHaveBeenCalledTimes(1);
});
it("evicts a stale cached tab-selection miss once and succeeds on a fresh reconnect", async () => {
const stale = makeBrowser([
{ targetId: "TARGET_A", url: "https://alpha.example" },
{ targetId: "TARGET_C", url: "https://charlie.example" },
]);
const fresh = makeBrowser([
{ targetId: "TARGET_A", url: "https://alpha.example" },
{ targetId: "TARGET_B", url: "https://beta.example" },
]);
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" });
const resolved = await getPageForTargetId({
cdpUrl: "http://127.0.0.1:9333",
targetId: "TARGET_B",
});
expect(resolved).toBe(fresh.pages[1]);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(stale.browserClose).toHaveBeenCalledTimes(1);
});
it("fails after a single reconnect when the refreshed browser is still page-less", async () => {
const stale = makeBrowser([]);
const stillBroken = makeBrowser([]);
connectOverCdpSpy
.mockResolvedValueOnce(stale.browser)
.mockResolvedValueOnce(stillBroken.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9444" });
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9444" })).rejects.toThrow(
"No pages available in the connected browser.",
);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
expect(stale.browserClose).toHaveBeenCalledTimes(1);
});
it("does not add an extra top-level retry for non-recoverable connect failures", async () => {
setCdpConnectRetryDelayMsForTests(0);
connectOverCdpSpy.mockRejectedValue(new Error("connectOverCDP exploded"));
getChromeWebSocketUrlSpy.mockResolvedValue(null);
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9555" })).rejects.toThrow(
"connectOverCDP exploded",
);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(3);
});
});

View File

@@ -1132,11 +1132,11 @@ async function getAllPages(browser: Browser): Promise<Page[]> {
return pages;
}
async function partitionAccessiblePages(opts: {
cdpUrl: string;
pages: Page[];
}): Promise<{ accessible: Page[]; blockedCount: number }> {
const accessible: Page[] = [];
async function partitionAccessiblePages(opts: { cdpUrl: string; pages: Page[] }): Promise<{
accessible: Array<{ page: Page; targetId: string | null }>;
blockedCount: number;
}> {
const accessible: Array<{ page: Page; targetId: string | null }> = [];
let blockedCount = 0;
for (const page of opts.pages) {
if (isBlockedPageRef(opts.cdpUrl, page)) {
@@ -1151,14 +1151,14 @@ async function partitionAccessiblePages(opts: {
blockedCount += 1;
continue;
}
accessible.push(page);
accessible.push({ page, targetId: null });
continue;
}
if (isBlockedTarget(opts.cdpUrl, targetId)) {
blockedCount += 1;
continue;
}
accessible.push(page);
accessible.push({ page, targetId });
}
return { accessible, blockedCount };
}
@@ -1174,100 +1174,6 @@ async function pageTargetId(page: Page): Promise<string | null> {
}
}
function matchPageByTargetList(
pages: Page[],
targets: Array<{ id: string; url: string; title?: string }>,
targetId: string,
): Page | null {
const target = targets.find((entry) => entry.id === targetId);
if (!target) {
return null;
}
const urlMatch = pages.filter((page) => page.url() === target.url);
if (urlMatch.length === 1) {
return urlMatch[0] ?? null;
}
if (urlMatch.length > 1) {
const sameUrlTargets = targets.filter((entry) => entry.url === target.url);
if (sameUrlTargets.length === urlMatch.length) {
const idx = sameUrlTargets.findIndex((entry) => entry.id === targetId);
if (idx >= 0 && idx < urlMatch.length) {
return urlMatch[idx] ?? null;
}
}
}
return null;
}
async function findPageByTargetIdViaTargetList(
pages: Page[],
targetId: string,
cdpUrl: string,
ssrfPolicy?: SsrFPolicy,
): Promise<Page | null> {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
const cdpControlPolicy = scopeCdpPolicyToConfiguredEndpoint(cdpUrl, ssrfPolicy);
const targets = await fetchJson<
Array<{
id: string;
url: string;
title?: string;
}>
>(appendCdpPath(cdpHttpBase, "/json/list"), 2000, undefined, cdpControlPolicy);
return matchPageByTargetList(pages, targets, targetId);
}
async function findPageByTargetId(
browser: Browser,
targetId: string,
cdpUrl?: string,
ssrfPolicy?: SsrFPolicy,
): Promise<Page | null> {
const pages = await getAllPages(browser);
let resolvedViaCdp = false;
for (const page of pages) {
let tid: string | null;
try {
tid = await pageTargetId(page);
resolvedViaCdp = true;
} catch {
tid = null;
}
if (tid && tid === targetId) {
return page;
}
}
if (cdpUrl) {
try {
return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl, ssrfPolicy);
} catch {
// Ignore fetch errors and fall through to return null.
}
}
if (!resolvedViaCdp && pages.length === 1) {
return pages[0] ?? null;
}
return null;
}
async function resolvePageByTargetIdOrThrow(opts: {
cdpUrl: string;
targetId: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<Page> {
if (isBlockedTarget(opts.cdpUrl, opts.targetId)) {
throw new BlockedBrowserTargetError();
}
const { browser } = await connectBrowser(opts.cdpUrl, opts.ssrfPolicy);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl, opts.ssrfPolicy);
if (!page) {
throw new BrowserTabNotFoundError();
}
return page;
}
async function getPageForTargetIdOnce(opts: {
cdpUrl: string;
targetId?: string;
@@ -1292,25 +1198,14 @@ async function getPageForTargetIdOnce(opts: {
}
throw new Error("No pages available in the connected browser.");
}
const first = accessible[0];
const first = accessible[0].page;
if (!opts.targetId) {
return first;
}
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl, opts.ssrfPolicy);
const found = accessible.find((entry) => entry.targetId === opts.targetId)?.page;
if (found) {
if (isBlockedPageRef(opts.cdpUrl, found)) {
throw new BlockedBrowserTargetError();
}
const foundTargetId = await pageTargetId(found).catch(() => null);
if (foundTargetId && isBlockedTarget(opts.cdpUrl, foundTargetId)) {
throw new BlockedBrowserTargetError();
}
return found;
}
// If Playwright only exposes a single Page total, use it as a best-effort fallback.
if (pages.length === 1) {
return first;
}
throw new BrowserTabNotFoundError();
}
@@ -2006,13 +1901,12 @@ export async function createPageViaPlaywright(
* Close a page/tab by targetId using the persistent Playwright connection.
* Used for remote profiles where HTTP-based /json/close is ephemeral.
*/
/** Close a Playwright page by CDP target id. */
export async function closePageByTargetIdViaPlaywright(opts: {
cdpUrl: string;
targetId: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<void> {
const page = await resolvePageByTargetIdOrThrow(opts);
const page = await getPageForTargetId(opts);
await page.close();
}
@@ -2020,13 +1914,12 @@ export async function closePageByTargetIdViaPlaywright(opts: {
* Focus a page/tab by targetId using the persistent Playwright connection.
* Used for remote profiles where HTTP-based /json/activate can be ephemeral.
*/
/** Bring a Playwright page to the front by CDP target id. */
export async function focusPageByTargetIdViaPlaywright(opts: {
cdpUrl: string;
targetId: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<void> {
const page = await resolvePageByTargetIdOrThrow(opts);
const page = await getPageForTargetId(opts);
try {
await page.bringToFront();
} catch (err) {