Files
openclaw/src/browser/server-context.remote-tab-ops.test.ts
Vincent Koc 5d53b61d9e fix(browser): honor profile attachOnly for loopback CDP (#31429)
* config(browser): allow profile attachOnly field

* config(schema): accept profile attachOnly

* browser(config): resolve per-profile attachOnly

* browser(runtime): honor profile attachOnly checks

* browser(routes): expose profile attachOnly in status

* config(labels): add browser profile attachOnly label

* config(help): document browser profile attachOnly

* test(config): cover profile attachOnly resolution

* test(browser): cover profile attachOnly runtime path

* test(config): include profile attachOnly help target

* changelog: note profile attachOnly override

* browser(runtime): prioritize attachOnly over loopback ownership error

* test(browser): cover attachOnly ws-failure ownership path
2026-03-02 00:49:57 -08:00

373 lines
13 KiB
TypeScript

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 * as chromeModule from "./chrome.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import * as pwAiModule from "./pw-ai-module.js";
import type { BrowserServerState } from "./server-context.js";
import { createBrowserRouteContext } from "./server-context.js";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
function makeState(
profile: "remote" | "openclaw",
): BrowserServerState & { profiles: Map<string, { lastTargetId?: string | null }> } {
return {
// oxlint-disable-next-line typescript/no-explicit-any
server: null as any,
port: 0,
resolved: {
enabled: true,
controlPort: 18791,
cdpPortRangeStart: 18800,
cdpPortRangeEnd: 18899,
cdpProtocol: profile === "remote" ? "https" : "http",
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
cdpIsLoopback: profile !== "remote",
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
evaluateEnabled: false,
extraArgs: [],
color: "#FF4500",
headless: true,
noSandbox: false,
attachOnly: false,
ssrfPolicy: { allowPrivateNetwork: true },
defaultProfile: profile,
profiles: {
remote: {
cdpUrl: "https://browserless.example/chrome?token=abc",
cdpPort: 443,
color: "#00AA00",
},
openclaw: { cdpPort: 18800, color: "#FF4500" },
},
},
profiles: new Map(),
};
}
function makeUnexpectedFetchMock() {
return vi.fn(async () => {
throw new Error("unexpected fetch");
});
}
function createRemoteRouteHarness(fetchMock?: ReturnType<typeof vi.fn>) {
const activeFetchMock = fetchMock ?? makeUnexpectedFetchMock();
global.fetch = withFetchPreconnect(activeFetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
return { state, remote: ctx.forProfile("remote"), fetchMock: activeFetchMock };
}
function createSequentialPageLister<T>(responses: T[]) {
return vi.fn(async () => {
const next = responses.shift();
if (!next) {
throw new Error("no more responses");
}
return next;
});
}
type JsonListEntry = {
id: string;
title: string;
url: string;
webSocketDebuggerUrl: string;
type: "page";
};
function createJsonListFetchMock(entries: JsonListEntry[]) {
return 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 () => entries,
} as unknown as Response;
});
}
describe("browser server-context remote profile tab operations", () => {
it("uses profile-level attachOnly when global attachOnly is false", async () => {
const state = makeState("openclaw");
state.resolved.attachOnly = false;
state.resolved.profiles.openclaw = {
cdpPort: 18800,
attachOnly: true,
color: "#FF4500",
};
const reachableMock = vi.mocked(chromeModule.isChromeReachable).mockResolvedValueOnce(false);
const launchMock = vi.mocked(chromeModule.launchOpenClawChrome);
const ctx = createBrowserRouteContext({ getState: () => state });
await expect(ctx.forProfile("openclaw").ensureBrowserAvailable()).rejects.toThrow(
/attachOnly is enabled/i,
);
expect(reachableMock).toHaveBeenCalled();
expect(launchMock).not.toHaveBeenCalled();
});
it("keeps attachOnly websocket failures off the loopback ownership error path", async () => {
const state = makeState("openclaw");
state.resolved.attachOnly = false;
state.resolved.profiles.openclaw = {
cdpPort: 18800,
attachOnly: true,
color: "#FF4500",
};
const httpReachableMock = vi.mocked(chromeModule.isChromeReachable).mockResolvedValueOnce(true);
const wsReachableMock = vi.mocked(chromeModule.isChromeCdpReady).mockResolvedValueOnce(false);
const launchMock = vi.mocked(chromeModule.launchOpenClawChrome);
const ctx = createBrowserRouteContext({ getState: () => state });
await expect(ctx.forProfile("openclaw").ensureBrowserAvailable()).rejects.toThrow(
/attachOnly is enabled and CDP websocket/i,
);
expect(httpReachableMock).toHaveBeenCalled();
expect(wsReachableMock).toHaveBeenCalled();
expect(launchMock).not.toHaveBeenCalled();
});
it("uses Playwright tab operations when available", async () => {
const listPagesViaPlaywright = vi.fn(async () => [
{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
]);
const createPageViaPlaywright = vi.fn(async () => ({
targetId: "T2",
title: "Tab 2",
url: "http://127.0.0.1:3000",
type: "page",
}));
const closePageByTargetIdViaPlaywright = vi.fn(async () => {});
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
listPagesViaPlaywright,
createPageViaPlaywright,
closePageByTargetIdViaPlaywright,
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
const { state, remote, fetchMock } = createRemoteRouteHarness();
const tabs = await remote.listTabs();
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
const opened = await remote.openTab("http://127.0.0.1:3000");
expect(opened.targetId).toBe("T2");
expect(state.profiles.get("remote")?.lastTargetId).toBe("T2");
expect(createPageViaPlaywright).toHaveBeenCalledWith({
cdpUrl: "https://browserless.example/chrome?token=abc",
url: "http://127.0.0.1:3000",
ssrfPolicy: { allowPrivateNetwork: true },
});
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 () => {
const responses = [
// ensureTabAvailable() calls listTabs twice
[
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
],
[
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
],
// second ensureTabAvailable() calls listTabs twice, order flips
[
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
],
[
{ targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
{ targetId: "A", title: "A", url: "https://example.com", type: "page" },
],
];
const listPagesViaPlaywright = vi.fn(async () => {
const next = responses.shift();
if (!next) {
throw new Error("no more responses");
}
return next;
});
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
listPagesViaPlaywright,
createPageViaPlaywright: vi.fn(async () => {
throw new Error("unexpected create");
}),
closePageByTargetIdViaPlaywright: vi.fn(async () => {
throw new Error("unexpected close");
}),
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
const { remote } = createRemoteRouteHarness();
const first = await remote.ensureTabAvailable();
expect(first.targetId).toBe("A");
const second = await remote.ensureTabAvailable();
expect(second.targetId).toBe("A");
});
it("falls back to the only tab for remote profiles when targetId is stale", async () => {
const responses = [
[{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }],
[{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }],
];
const listPagesViaPlaywright = createSequentialPageLister(responses);
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
listPagesViaPlaywright,
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
const { remote } = createRemoteRouteHarness();
const chosen = await remote.ensureTabAvailable("STALE_TARGET");
expect(chosen.targetId).toBe("T1");
});
it("keeps rejecting stale targetId for remote profiles when multiple tabs exist", async () => {
const responses = [
[
{ 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" },
],
];
const listPagesViaPlaywright = createSequentialPageLister(responses);
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
listPagesViaPlaywright,
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
const { remote } = createRemoteRouteHarness();
await expect(remote.ensureTabAvailable("STALE_TARGET")).rejects.toThrow(/tab not found/i);
});
it("uses Playwright focus for remote profiles when available", async () => {
const listPagesViaPlaywright = vi.fn(async () => [
{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
]);
const focusPageByTargetIdViaPlaywright = vi.fn(async () => {});
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
listPagesViaPlaywright,
focusPageByTargetIdViaPlaywright,
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
const { state, remote, fetchMock } = createRemoteRouteHarness();
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.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({
listPagesViaPlaywright: vi.fn(async () => {
throw new Error("boom");
}),
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
const { remote, fetchMock } = createRemoteRouteHarness();
await expect(remote.listTabs()).rejects.toThrow(/boom/);
expect(fetchMock).not.toHaveBeenCalled();
});
it("falls back to /json/list when Playwright is not available", async () => {
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null);
const fetchMock = createJsonListFetchMock([
{
id: "T1",
title: "Tab 1",
url: "https://example.com",
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1",
type: "page",
},
]);
const { remote } = createRemoteRouteHarness(fetchMock);
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 () => {
const createTargetViaCdp = vi
.spyOn(cdpModule, "createTargetViaCdp")
.mockResolvedValue({ targetId: "CREATED" });
const fetchMock = createJsonListFetchMock([
{
id: "CREATED",
title: "New Tab",
url: "http://127.0.0.1:8080",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
type: "page",
},
]);
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("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();
});
});