diff --git a/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts b/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts index 3947f7d4fece..1bd753668da7 100644 --- a/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts +++ b/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts @@ -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 () => { diff --git a/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts b/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts deleted file mode 100644 index 31d8b86a2834..000000000000 --- a/extensions/browser/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts +++ /dev/null @@ -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(); - return { - ...actual, - fetchWithSsrFGuard: (...args: Parameters) => { - 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; - pages: import("playwright-core").Page[]; -}; - -type FetchInitWithDispatcher = RequestInit & { dispatcher?: unknown }; - -function requireFetchCall(fetchSpy: { - mock: { calls: Parameters[] }; -}): Parameters { - const [call] = fetchSpy.mock.calls; - if (!call) { - throw new Error("expected fallback fetch call"); - } - return call; -} - -function requireFetchInit(init: Parameters[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(); - - 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); - }); -}); diff --git a/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts b/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts new file mode 100644 index 000000000000..4ed5912754fb --- /dev/null +++ b/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts @@ -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; + pages: import("playwright-core").Page[]; + pageActions: Array<{ + bringToFront: ReturnType; + close: ReturnType; + }>; +}; + +function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle { + const browserClose = vi.fn(async () => {}); + const specByPage = new Map(); + 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); + }); +}); diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index 0937c566fe22..39c0a2e0c99e 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -1132,11 +1132,11 @@ async function getAllPages(browser: Browser): Promise { 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 { } } -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 { - 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 { - 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 { - 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 { - 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 { - const page = await resolvePageByTargetIdOrThrow(opts); + const page = await getPageForTargetId(opts); try { await page.bringToFront(); } catch (err) {