perf: consolidate browser test entrypoints

This commit is contained in:
Peter Steinberger
2026-04-24 02:08:38 +01:00
parent 27b8aa1ddf
commit b2cface9d5
12 changed files with 303 additions and 313 deletions

View File

@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
function createProfile(overrides: Partial<ResolvedBrowserProfile>): ResolvedBrowserProfile {
return {
name: "remote",
cdpPort: 9223,
cdpUrl: "http://172.29.128.1:9223",
cdpHost: "172.29.128.1",
cdpIsLoopback: false,
color: "#123456",
driver: "openclaw",
attachOnly: false,
...overrides,
};
}
describe("CDP reachability policy", () => {
it("allows the selected remote profile CDP host without widening browser navigation policy", async () => {
const browserPolicy = {};
const profile = createProfile({});
expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toEqual({
allowedHostnames: ["172.29.128.1"],
});
expect(browserPolicy).toEqual({});
await expect(
assertBrowserNavigationAllowed({
url: "http://172.29.128.1/",
ssrfPolicy: browserPolicy,
}),
).rejects.toThrow(/private\/internal\/special-use ip address/i);
});
it("merges the selected remote profile CDP host with existing CDP policy hostnames", () => {
const profile = createProfile({});
expect(
resolveCdpReachabilityPolicy(profile, {
allowedHostnames: ["metadata.internal"],
}),
).toEqual({
allowedHostnames: ["metadata.internal", "172.29.128.1"],
});
});
it("keeps local managed loopback CDP control outside browser SSRF policy", () => {
const profile = createProfile({
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(resolveCdpReachabilityPolicy(profile, {})).toBeUndefined();
});
});

View File

@@ -1,69 +0,0 @@
import { describe, expect, it } from "vitest";
import {
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS,
PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS,
resolveCdpReachabilityTimeouts,
} from "./cdp-timeouts.js";
describe("resolveCdpReachabilityTimeouts", () => {
it("uses loopback defaults when timeout is omitted", () => {
expect(
resolveCdpReachabilityTimeouts({
profileIsLoopback: true,
timeoutMs: undefined,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
}),
).toEqual({
httpTimeoutMs: PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
wsTimeoutMs: PROFILE_HTTP_REACHABILITY_TIMEOUT_MS * 2,
});
});
it("clamps loopback websocket timeout range", () => {
const low = resolveCdpReachabilityTimeouts({
profileIsLoopback: true,
timeoutMs: 1,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
});
const high = resolveCdpReachabilityTimeouts({
profileIsLoopback: true,
timeoutMs: 5000,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
});
expect(low.wsTimeoutMs).toBe(PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS);
expect(high.wsTimeoutMs).toBe(PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS);
});
it("enforces remote minimums even when caller passes lower timeout", () => {
expect(
resolveCdpReachabilityTimeouts({
profileIsLoopback: false,
timeoutMs: 200,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
}),
).toEqual({
httpTimeoutMs: 1500,
wsTimeoutMs: 3000,
});
});
it("uses remote defaults when timeout is omitted", () => {
expect(
resolveCdpReachabilityTimeouts({
profileIsLoopback: false,
timeoutMs: undefined,
remoteHttpTimeoutMs: 1750,
remoteHandshakeTimeoutMs: 3250,
}),
).toEqual({
httpTimeoutMs: 1750,
wsTimeoutMs: 3250,
});
});
});

View File

@@ -1,4 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import {
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS,
PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS,
resolveCdpReachabilityTimeouts,
} from "./cdp-timeouts.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
@@ -138,3 +147,119 @@ describe("cdp helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
});
function createProfile(overrides: Partial<ResolvedBrowserProfile>): ResolvedBrowserProfile {
return {
name: "remote",
cdpPort: 9223,
cdpUrl: "http://172.29.128.1:9223",
cdpHost: "172.29.128.1",
cdpIsLoopback: false,
color: "#123456",
driver: "openclaw",
attachOnly: false,
...overrides,
};
}
describe("resolveCdpReachabilityTimeouts", () => {
it("uses loopback defaults when timeout is omitted", () => {
expect(
resolveCdpReachabilityTimeouts({
profileIsLoopback: true,
timeoutMs: undefined,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
}),
).toEqual({
httpTimeoutMs: PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
wsTimeoutMs: PROFILE_HTTP_REACHABILITY_TIMEOUT_MS * 2,
});
});
it("clamps loopback websocket timeout range", () => {
const low = resolveCdpReachabilityTimeouts({
profileIsLoopback: true,
timeoutMs: 1,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
});
const high = resolveCdpReachabilityTimeouts({
profileIsLoopback: true,
timeoutMs: 5000,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
});
expect(low.wsTimeoutMs).toBe(PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS);
expect(high.wsTimeoutMs).toBe(PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS);
});
it("enforces remote minimums even when caller passes lower timeout", () => {
expect(
resolveCdpReachabilityTimeouts({
profileIsLoopback: false,
timeoutMs: 200,
remoteHttpTimeoutMs: 1500,
remoteHandshakeTimeoutMs: 3000,
}),
).toEqual({
httpTimeoutMs: 1500,
wsTimeoutMs: 3000,
});
});
it("uses remote defaults when timeout is omitted", () => {
expect(
resolveCdpReachabilityTimeouts({
profileIsLoopback: false,
timeoutMs: undefined,
remoteHttpTimeoutMs: 1750,
remoteHandshakeTimeoutMs: 3250,
}),
).toEqual({
httpTimeoutMs: 1750,
wsTimeoutMs: 3250,
});
});
});
describe("CDP reachability policy", () => {
it("allows the selected remote profile CDP host without widening browser navigation policy", async () => {
const browserPolicy = {};
const profile = createProfile({});
expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toEqual({
allowedHostnames: ["172.29.128.1"],
});
expect(browserPolicy).toEqual({});
await expect(
assertBrowserNavigationAllowed({
url: "http://172.29.128.1/",
ssrfPolicy: browserPolicy,
}),
).rejects.toThrow(/private\/internal\/special-use ip address/i);
});
it("merges the selected remote profile CDP host with existing CDP policy hostnames", () => {
const profile = createProfile({});
expect(
resolveCdpReachabilityPolicy(profile, {
allowedHostnames: ["metadata.internal"],
}),
).toEqual({
allowedHostnames: ["metadata.internal", "172.29.128.1"],
});
});
it("keeps local managed loopback CDP control outside browser SSRF policy", () => {
const profile = createProfile({
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(resolveCdpReachabilityPolicy(profile, {})).toBeUndefined();
});
});

View File

@@ -7,7 +7,13 @@ import "../../test-support/browser-security-runtime.mock.js";
import { isDirectCdpWebSocketEndpoint, isWebSocketUrl } from "./cdp.helpers.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import { parseHttpUrl } from "./config.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
import {
BROWSER_ENDPOINT_BLOCKED_MESSAGE,
BROWSER_NAVIGATION_BLOCKED_MESSAGE,
BrowserCdpEndpointBlockedError,
BrowserValidationError,
toBrowserErrorResponse,
} from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
describe("cdp", () => {
@@ -460,6 +466,45 @@ describe("cdp", () => {
});
});
describe("browser error mapping", () => {
it("maps blocked browser targets to conflict responses", () => {
const err = new Error(
"Browser target is unavailable after SSRF policy blocked its navigation.",
);
err.name = "BlockedBrowserTargetError";
expect(toBrowserErrorResponse(err)).toEqual({
status: 409,
message: "Browser target is unavailable after SSRF policy blocked its navigation.",
});
});
it("preserves BrowserError mappings", () => {
expect(toBrowserErrorResponse(new BrowserValidationError("bad input"))).toEqual({
status: 400,
message: "bad input",
});
});
it("sanitizes navigation-target SSRF policy errors without leaking raw policy details", () => {
expect(
toBrowserErrorResponse(
new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"),
),
).toEqual({
status: 400,
message: BROWSER_NAVIGATION_BLOCKED_MESSAGE,
});
});
it("maps CDP endpoint policy blocks to a distinct endpoint-scoped message", () => {
expect(toBrowserErrorResponse(new BrowserCdpEndpointBlockedError())).toEqual({
status: 400,
message: BROWSER_ENDPOINT_BLOCKED_MESSAGE,
});
});
});
describe("isWebSocketUrl", () => {
it("returns true for ws:// URLs", () => {
expect(isWebSocketUrl("ws://127.0.0.1:9222")).toBe(true);

View File

@@ -1,42 +0,0 @@
import fs from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
parseBrowserMajorVersion,
resolveGoogleChromeExecutableForPlatform,
} from "./chrome.executables.js";
describe("chrome executables", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("parses odd dotted browser version tokens using the last match", () => {
expect(parseBrowserMajorVersion("Chromium 3.0/1.2.3")).toBe(1);
});
it("returns null when no dotted version token exists", () => {
expect(parseBrowserMajorVersion("no version here")).toBeNull();
});
it("classifies beta Linux Google Chrome builds as canary", () => {
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
return String(candidate) === "/usr/bin/google-chrome-beta";
});
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
kind: "canary",
path: "/usr/bin/google-chrome-beta",
});
});
it("classifies unstable Linux Google Chrome builds as canary", () => {
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
return String(candidate) === "/usr/bin/google-chrome-unstable";
});
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
kind: "canary",
path: "/usr/bin/google-chrome-unstable",
});
});
});

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildOpenClawChromeLaunchArgs } from "./chrome.js";
describe("browser chrome launch args", () => {
it("does not force an about:blank tab at startup", () => {
const args = buildOpenClawChromeLaunchArgs({
resolved: {
enabled: true,
controlPort: 18791,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPortRangeStart: 18800,
cdpPortRangeEnd: 18810,
evaluateEnabled: false,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
extraArgs: [],
color: "#FF4500",
headless: false,
noSandbox: false,
attachOnly: false,
ssrfPolicy: { allowPrivateNetwork: true },
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500" },
},
},
profile: {
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpPort: 18800,
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#FF4500",
driver: "openclaw",
attachOnly: false,
},
userDataDir: "/tmp/openclaw-test-user-data",
});
expect(args).not.toContain("about:blank");
expect(args).toContain("--remote-debugging-port=18800");
expect(args).toContain("--user-data-dir=/tmp/openclaw-test-user-data");
});
});

View File

@@ -6,6 +6,10 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import {
parseBrowserMajorVersion,
resolveGoogleChromeExecutableForPlatform,
} from "./chrome.executables.js";
import {
decorateOpenClawProfile,
diagnoseChromeCdp,
@@ -13,6 +17,7 @@ import {
findChromeExecutableMac,
findChromeExecutableWindows,
formatChromeCdpDiagnostic,
buildOpenClawChromeLaunchArgs,
getChromeWebSocketUrl,
isChromeCdpReady,
isChromeReachable,
@@ -575,3 +580,83 @@ describe("browser chrome helpers", () => {
expect(proc.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
});
});
describe("chrome executables", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("parses odd dotted browser version tokens using the last match", () => {
expect(parseBrowserMajorVersion("Chromium 3.0/1.2.3")).toBe(1);
});
it("returns null when no dotted version token exists", () => {
expect(parseBrowserMajorVersion("no version here")).toBeNull();
});
it("classifies beta Linux Google Chrome builds as canary", () => {
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
return String(candidate) === "/usr/bin/google-chrome-beta";
});
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
kind: "canary",
path: "/usr/bin/google-chrome-beta",
});
});
it("classifies unstable Linux Google Chrome builds as canary", () => {
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
return String(candidate) === "/usr/bin/google-chrome-unstable";
});
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
kind: "canary",
path: "/usr/bin/google-chrome-unstable",
});
});
});
describe("browser chrome launch args", () => {
it("does not force an about:blank tab at startup", () => {
const args = buildOpenClawChromeLaunchArgs({
resolved: {
enabled: true,
controlPort: 18791,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPortRangeStart: 18800,
cdpPortRangeEnd: 18810,
evaluateEnabled: false,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
extraArgs: [],
color: "#FF4500",
headless: false,
noSandbox: false,
attachOnly: false,
ssrfPolicy: { allowPrivateNetwork: true },
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500" },
},
},
profile: {
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpPort: 18800,
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#FF4500",
driver: "openclaw",
attachOnly: false,
},
userDataDir: "/tmp/openclaw-test-user-data",
});
expect(args).not.toContain("about:blank");
expect(args).toContain("--remote-debugging-port=18800");
expect(args).toContain("--user-data-dir=/tmp/openclaw-test-user-data");
});
});

View File

@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import {
BROWSER_ENDPOINT_BLOCKED_MESSAGE,
BROWSER_NAVIGATION_BLOCKED_MESSAGE,
BrowserCdpEndpointBlockedError,
BrowserValidationError,
toBrowserErrorResponse,
} from "./errors.js";
describe("browser error mapping", () => {
it("maps blocked browser targets to conflict responses", () => {
const err = new Error(
"Browser target is unavailable after SSRF policy blocked its navigation.",
);
err.name = "BlockedBrowserTargetError";
expect(toBrowserErrorResponse(err)).toEqual({
status: 409,
message: "Browser target is unavailable after SSRF policy blocked its navigation.",
});
});
it("preserves BrowserError mappings", () => {
expect(toBrowserErrorResponse(new BrowserValidationError("bad input"))).toEqual({
status: 400,
message: "bad input",
});
});
it("sanitizes navigation-target SSRF policy errors without leaking raw policy details", () => {
expect(
toBrowserErrorResponse(
new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"),
),
).toEqual({
status: 400,
message: BROWSER_NAVIGATION_BLOCKED_MESSAGE,
});
});
it("maps CDP endpoint policy blocks to a distinct endpoint-scoped message", () => {
expect(toBrowserErrorResponse(new BrowserCdpEndpointBlockedError())).toEqual({
status: 400,
message: BROWSER_ENDPOINT_BLOCKED_MESSAGE,
});
});
});

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { isDefaultBrowserPluginEnabled } from "../plugin-enabled.js";
describe("isDefaultBrowserPluginEnabled", () => {
it("defaults to enabled", () => {
expect(isDefaultBrowserPluginEnabled({} as OpenClawConfig)).toBe(true);
});
it("respects explicit plugin disablement", () => {
expect(
isDefaultBrowserPluginEnabled({
plugins: {
entries: {
browser: {
enabled: false,
},
},
},
} as OpenClawConfig),
).toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { isPersistentBrowserProfileMutation } from "./request-policy.js";
import { matchBrowserUrlPattern } from "./url-pattern.js";
describe("isPersistentBrowserProfileMutation", () => {
it.each([
@@ -23,3 +24,27 @@ describe("isPersistentBrowserProfileMutation", () => {
expect(isPersistentBrowserProfileMutation(method, path)).toBe(false);
});
});
describe("browser url pattern matching", () => {
it("matches exact URLs", () => {
expect(matchBrowserUrlPattern("https://example.com/a", "https://example.com/a")).toBe(true);
expect(matchBrowserUrlPattern("https://example.com/a", "https://example.com/b")).toBe(false);
});
it("matches substring patterns without wildcards", () => {
expect(matchBrowserUrlPattern("example.com", "https://example.com/a")).toBe(true);
expect(matchBrowserUrlPattern("/dash", "https://example.com/app/dash")).toBe(true);
expect(matchBrowserUrlPattern("nope", "https://example.com/a")).toBe(false);
});
it("matches glob patterns", () => {
expect(matchBrowserUrlPattern("**/dash", "https://example.com/app/dash")).toBe(true);
expect(matchBrowserUrlPattern("https://example.com/*", "https://example.com/a")).toBe(true);
expect(matchBrowserUrlPattern("https://example.com/*", "https://other.com/a")).toBe(false);
});
it("rejects empty patterns", () => {
expect(matchBrowserUrlPattern("", "https://example.com")).toBe(false);
expect(matchBrowserUrlPattern(" ", "https://example.com")).toBe(false);
});
});

View File

@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { matchBrowserUrlPattern } from "./url-pattern.js";
describe("browser url pattern matching", () => {
it("matches exact URLs", () => {
expect(matchBrowserUrlPattern("https://example.com/a", "https://example.com/a")).toBe(true);
expect(matchBrowserUrlPattern("https://example.com/a", "https://example.com/b")).toBe(false);
});
it("matches substring patterns without wildcards", () => {
expect(matchBrowserUrlPattern("example.com", "https://example.com/a")).toBe(true);
expect(matchBrowserUrlPattern("/dash", "https://example.com/app/dash")).toBe(true);
expect(matchBrowserUrlPattern("nope", "https://example.com/a")).toBe(false);
});
it("matches glob patterns", () => {
expect(matchBrowserUrlPattern("**/dash", "https://example.com/app/dash")).toBe(true);
expect(matchBrowserUrlPattern("https://example.com/*", "https://example.com/a")).toBe(true);
expect(matchBrowserUrlPattern("https://example.com/*", "https://other.com/a")).toBe(false);
});
it("rejects empty patterns", () => {
expect(matchBrowserUrlPattern("", "https://example.com")).toBe(false);
expect(matchBrowserUrlPattern(" ", "https://example.com")).toBe(false);
});
});

View File

@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "./config/config.js";
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
import { createBrowserPluginService } from "./plugin-service.js";
const SERVICE_CONTEXT = {
@@ -61,3 +63,23 @@ describe("createBrowserPluginService", () => {
);
});
});
describe("isDefaultBrowserPluginEnabled", () => {
it("defaults to enabled", () => {
expect(isDefaultBrowserPluginEnabled({} as OpenClawConfig)).toBe(true);
});
it("respects explicit plugin disablement", () => {
expect(
isDefaultBrowserPluginEnabled({
plugins: {
entries: {
browser: {
enabled: false,
},
},
},
} as OpenClawConfig),
).toBe(false);
});
});