Files
clawdbot/src/browser/server-context.remote-tab-ops.test.ts
Peter Steinberger a76cbc43bb fix(browser): remote profile tab ops follow-up (#1060) (thanks @mukhtharcm)
Landed via follow-up to #1057.

Gate: pnpm lint && pnpm build && pnpm test
2026-01-17 01:28:22 +00:00

286 lines
9.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { BrowserServerState } from "./server-context.js";
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => true),
isChromeReachable: vi.fn(async () => true),
launchClawdChrome: vi.fn(async () => {
throw new Error("unexpected launch");
}),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {}),
}));
function makeState(
profile: "remote" | "clawd",
): BrowserServerState & { profiles: Map<string, { lastTargetId?: string | null }> } {
return {
// biome-ignore lint/suspicious/noExplicitAny: test stub
server: null as any,
port: 0,
resolved: {
enabled: true,
controlUrl: "http://127.0.0.1:18791",
controlHost: "127.0.0.1",
controlPort: 18791,
cdpProtocol: profile === "remote" ? "https" : "http",
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
cdpIsLoopback: profile !== "remote",
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
color: "#FF4500",
headless: true,
noSandbox: false,
attachOnly: false,
defaultProfile: profile,
profiles: {
remote: {
cdpUrl: "https://browserless.example/chrome?token=abc",
cdpPort: 443,
color: "#00AA00",
},
clawd: { cdpPort: 18800, color: "#FF4500" },
},
},
profiles: new Map(),
};
}
describe("browser server-context remote profile tab operations", () => {
it("uses Playwright tab operations when available", async () => {
vi.resetModules();
const listPagesViaPlaywright = vi.fn(async () => [
{ targetId: "T1", title: "Tab 1", url: "https://a.example", type: "page" },
]);
const createPageViaPlaywright = vi.fn(async () => ({
targetId: "T2",
title: "Tab 2",
url: "https://b.example",
type: "page",
}));
const closePageByTargetIdViaPlaywright = vi.fn(async () => {});
vi.doMock("./pw-ai.js", () => ({
listPagesViaPlaywright,
createPageViaPlaywright,
closePageByTargetIdViaPlaywright,
}));
const fetchMock = vi.fn(async () => {
throw new Error("unexpected fetch");
});
// @ts-expect-error test override
global.fetch = fetchMock;
const { createBrowserRouteContext } = await import("./server-context.js");
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
const remote = ctx.forProfile("remote");
const tabs = await remote.listTabs();
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
const opened = await remote.openTab("https://b.example");
expect(opened.targetId).toBe("T2");
expect(state.profiles.get("remote")?.lastTargetId).toBe("T2");
await remote.closeTab("T1");
expect(closePageByTargetIdViaPlaywright).toHaveBeenCalledWith({
cdpUrl: "https://browserless.example/chrome?token=abc",
targetId: "T1",
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("prefers lastTargetId for remote profiles when targetId is omitted", async () => {
vi.resetModules();
const responses = [
// ensureTabAvailable() calls listTabs twice
[
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
],
[
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
],
// second ensureTabAvailable() calls listTabs twice, order flips
[
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
],
[
{ targetId: "B", title: "B", url: "https://b.example", type: "page" },
{ targetId: "A", title: "A", url: "https://a.example", type: "page" },
],
];
const listPagesViaPlaywright = vi.fn(async () => {
const next = responses.shift();
if (!next) throw new Error("no more responses");
return next;
});
vi.doMock("./pw-ai.js", () => ({
listPagesViaPlaywright,
createPageViaPlaywright: vi.fn(async () => {
throw new Error("unexpected create");
}),
closePageByTargetIdViaPlaywright: vi.fn(async () => {
throw new Error("unexpected close");
}),
}));
const fetchMock = vi.fn(async () => {
throw new Error("unexpected fetch");
});
// @ts-expect-error test override
global.fetch = fetchMock;
const { createBrowserRouteContext } = await import("./server-context.js");
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
const remote = ctx.forProfile("remote");
const first = await remote.ensureTabAvailable();
expect(first.targetId).toBe("A");
const second = await remote.ensureTabAvailable();
expect(second.targetId).toBe("A");
});
it("uses Playwright focus for remote profiles when available", async () => {
vi.resetModules();
const listPagesViaPlaywright = vi.fn(async () => [
{ targetId: "T1", title: "Tab 1", url: "https://a.example", type: "page" },
]);
const focusPageByTargetIdViaPlaywright = vi.fn(async () => {});
vi.doMock("./pw-ai.js", () => ({
listPagesViaPlaywright,
focusPageByTargetIdViaPlaywright,
}));
const fetchMock = vi.fn(async () => {
throw new Error("unexpected fetch");
});
// @ts-expect-error test override
global.fetch = fetchMock;
const { createBrowserRouteContext } = await import("./server-context.js");
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
const remote = ctx.forProfile("remote");
await remote.focusTab("T1");
expect(focusPageByTargetIdViaPlaywright).toHaveBeenCalledWith({
cdpUrl: "https://browserless.example/chrome?token=abc",
targetId: "T1",
});
expect(fetchMock).not.toHaveBeenCalled();
expect(state.profiles.get("remote")?.lastTargetId).toBe("T1");
});
it("does not swallow Playwright runtime errors for remote profiles", async () => {
vi.resetModules();
vi.doMock("./pw-ai.js", () => ({
listPagesViaPlaywright: vi.fn(async () => {
throw new Error("boom");
}),
}));
const fetchMock = vi.fn(async () => {
throw new Error("unexpected fetch");
});
// @ts-expect-error test override
global.fetch = fetchMock;
const { createBrowserRouteContext } = await import("./server-context.js");
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
const remote = ctx.forProfile("remote");
await expect(remote.listTabs()).rejects.toThrow(/boom/);
expect(fetchMock).not.toHaveBeenCalled();
});
it("falls back to /json/list when Playwright is not available", async () => {
vi.resetModules();
vi.doMock("./pw-ai.js", () => ({
listPagesViaPlaywright: undefined,
createPageViaPlaywright: undefined,
closePageByTargetIdViaPlaywright: undefined,
}));
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) throw new Error(`unexpected fetch: ${u}`);
return {
ok: true,
json: async () => [
{
id: "T1",
title: "Tab 1",
url: "https://a.example",
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1",
type: "page",
},
],
} as unknown as Response;
});
// @ts-expect-error test override
global.fetch = fetchMock;
const { createBrowserRouteContext } = await import("./server-context.js");
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
const remote = ctx.forProfile("remote");
const tabs = await remote.listTabs();
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("browser server-context tab selection state", () => {
it("updates lastTargetId when openTab is created via CDP", async () => {
vi.resetModules();
vi.doUnmock("./pw-ai.js");
vi.doMock("./cdp.js", async () => {
const actual = await vi.importActual<typeof import("./cdp.js")>("./cdp.js");
return {
...actual,
createTargetViaCdp: vi.fn(async () => ({ targetId: "CREATED" })),
};
});
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) throw new Error(`unexpected fetch: ${u}`);
return {
ok: true,
json: async () => [
{
id: "CREATED",
title: "New Tab",
url: "https://created.example",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
type: "page",
},
],
} as unknown as Response;
});
// @ts-expect-error test override
global.fetch = fetchMock;
const { createBrowserRouteContext } = await import("./server-context.js");
const state = makeState("clawd");
const ctx = createBrowserRouteContext({ getState: () => state });
const clawd = ctx.forProfile("clawd");
const opened = await clawd.openTab("https://created.example");
expect(opened.targetId).toBe("CREATED");
expect(state.profiles.get("clawd")?.lastTargetId).toBe("CREATED");
});
});