mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 07:51:38 +00:00
fix(browser): require exact Playwright target identity (#103177)
This commit is contained in:
committed by
GitHub
parent
a5d70e114c
commit
aef945f847
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user