import { afterEach, describe, expect, it, vi } from "vitest"; import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; import "./server-context.chrome-test-harness.js"; import * as cdpModule from "./cdp.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import { createBrowserRouteContext } from "./server-context.js"; import { makeManagedTabsWithNew, makeState, originalFetch, } from "./server-context.remote-tab-ops.harness.js"; afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); function seedRunningProfileState( state: ReturnType, profileName = "openclaw", ): void { (state.profiles as Map).set(profileName, { profile: { name: profileName }, running: { pid: 1234, proc: { on: vi.fn() } }, lastTargetId: null, }); } async function expectOldManagedTabClose(fetchMock: ReturnType): Promise { await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining("/json/close/OLD1"), expect.any(Object), ); }); } function createOldTabCleanupFetchMock( existingTabs: ReturnType, params?: { rejectNewTabClose?: boolean }, ): ReturnType { return vi.fn(async (url: unknown) => { const value = String(url); if (value.includes("/json/list")) { return { ok: true, json: async () => existingTabs } as unknown as Response; } if (value.includes("/json/close/OLD1")) { return { ok: true, json: async () => ({}) } as unknown as Response; } if (params?.rejectNewTabClose && value.includes("/json/close/NEW")) { throw new Error("cleanup must not close NEW"); } throw new Error(`unexpected fetch: ${value}`); }); } function createManagedTabListFetchMock(params: { existingTabs: ReturnType; onClose: (url: string) => Response | Promise; }): ReturnType { return vi.fn(async (url: unknown) => { const value = String(url); if (value.includes("/json/list")) { return { ok: true, json: async () => params.existingTabs } as unknown as Response; } if (value.includes("/json/close/")) { return await params.onClose(value); } throw new Error(`unexpected fetch: ${value}`); }); } async function openManagedTabWithRunningProfile(params: { fetchMock: ReturnType; url?: string; }) { global.fetch = withFetchPreconnect(params.fetchMock); const state = makeState("openclaw"); seedRunningProfileState(state); const ctx = createBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); return await openclaw.openTab(params.url ?? "http://127.0.0.1:3009"); } describe("browser server-context tab selection state", () => { it("updates lastTargetId when openTab is created via CDP", async () => { const createTargetViaCdp = vi .spyOn(cdpModule, "createTargetViaCdp") .mockResolvedValue({ 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: "http://127.0.0.1:8080", webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED", type: "page", }, ], } as unknown as Response; }); global.fetch = withFetchPreconnect(fetchMock); const state = makeState("openclaw"); const ctx = createBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); const opened = await openclaw.openTab("http://127.0.0.1:8080"); expect(opened.targetId).toBe("CREATED"); expect(state.profiles.get("openclaw")?.lastTargetId).toBe("CREATED"); expect(createTargetViaCdp).toHaveBeenCalledWith({ cdpUrl: "http://127.0.0.1:18800", url: "http://127.0.0.1:8080", ssrfPolicy: { allowPrivateNetwork: true }, }); }); it("closes excess managed tabs after opening a new tab", async () => { vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" }); const existingTabs = makeManagedTabsWithNew(); const fetchMock = createOldTabCleanupFetchMock(existingTabs); const opened = await openManagedTabWithRunningProfile({ fetchMock }); expect(opened.targetId).toBe("NEW"); await expectOldManagedTabClose(fetchMock); }); it("never closes the just-opened managed tab during cap cleanup", async () => { vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" }); const existingTabs = makeManagedTabsWithNew({ newFirst: true }); const fetchMock = createOldTabCleanupFetchMock(existingTabs, { rejectNewTabClose: true }); const opened = await openManagedTabWithRunningProfile({ fetchMock }); expect(opened.targetId).toBe("NEW"); await expectOldManagedTabClose(fetchMock); expect(fetchMock).not.toHaveBeenCalledWith( expect.stringContaining("/json/close/NEW"), expect.anything(), ); }); it("does not fail tab open when managed-tab cleanup list fails", async () => { vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" }); let listCount = 0; const fetchMock = vi.fn(async (url: unknown) => { const value = String(url); if (value.includes("/json/list")) { listCount += 1; if (listCount === 1) { return { ok: true, json: async () => [ { id: "NEW", title: "New Tab", url: "http://127.0.0.1:3009", webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW", type: "page", }, ], } as unknown as Response; } throw new Error("/json/list timeout"); } throw new Error(`unexpected fetch: ${value}`); }); global.fetch = withFetchPreconnect(fetchMock); const state = makeState("openclaw"); seedRunningProfileState(state); const ctx = createBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); const opened = await openclaw.openTab("http://127.0.0.1:3009"); expect(opened.targetId).toBe("NEW"); }); it("does not run managed tab cleanup in attachOnly mode", async () => { vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" }); const existingTabs = makeManagedTabsWithNew(); const fetchMock = createManagedTabListFetchMock({ existingTabs, onClose: () => { throw new Error("should not close tabs in attachOnly mode"); }, }); global.fetch = withFetchPreconnect(fetchMock); const state = makeState("openclaw"); state.resolved.attachOnly = true; const ctx = createBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); const opened = await openclaw.openTab("http://127.0.0.1:3009"); expect(opened.targetId).toBe("NEW"); expect(fetchMock).not.toHaveBeenCalledWith( expect.stringContaining("/json/close/"), expect.anything(), ); }); it("does not block openTab on slow best-effort cleanup closes", async () => { vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" }); const existingTabs = makeManagedTabsWithNew(); const fetchMock = createManagedTabListFetchMock({ existingTabs, onClose: (url) => { if (url.includes("/json/close/OLD1")) { return new Promise(() => {}); } throw new Error(`unexpected fetch: ${url}`); }, }); const opened = await Promise.race([ openManagedTabWithRunningProfile({ fetchMock }), new Promise((_, reject) => setTimeout(() => reject(new Error("openTab timed out waiting for cleanup")), 300), ), ]); expect(opened.targetId).toBe("NEW"); }); it("blocks unsupported non-network URLs before any HTTP tab-open fallback", async () => { const fetchMock = vi.fn(async () => { throw new Error("unexpected fetch"); }); global.fetch = withFetchPreconnect(fetchMock); const state = makeState("openclaw"); const ctx = createBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); await expect(openclaw.openTab("file:///etc/passwd")).rejects.toBeInstanceOf( InvalidBrowserNavigationUrlError, ); expect(fetchMock).not.toHaveBeenCalled(); }); });