mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
* 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
373 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|