perf(test): speed up browser test suites

This commit is contained in:
Peter Steinberger
2026-02-14 14:25:35 +00:00
parent 57f40a5da6
commit 493f6f458b
6 changed files with 242 additions and 248 deletions
@@ -1,53 +1,19 @@
import type { AddressInfo } from "node:net"; import { describe, expect, it, vi } from "vitest";
import { createServer } from "node:http"; import { __test } from "./client-fetch.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./bridge-auth-registry.js";
describe("fetchBrowserJson loopback auth (bridge auth registry)", () => { describe("fetchBrowserJson loopback auth (bridge auth registry)", () => {
afterEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it("falls back to per-port bridge auth when config auth is not available", async () => { it("falls back to per-port bridge auth when config auth is not available", async () => {
vi.doMock("../config/config.js", async (importOriginal) => { const port = 18765;
const original = await importOriginal<typeof import("../config/config.js")>(); const getBridgeAuthForPort = vi.fn((candidate: number) =>
return { candidate === port ? { token: "registry-token" } : undefined,
...original, );
loadConfig: () => ({}), const init = __test.withLoopbackBrowserAuth(`http://127.0.0.1:${port}/`, undefined, {
}; loadConfig: () => ({}),
resolveBrowserControlAuth: () => ({}),
getBridgeAuthForPort,
}); });
const headers = new Headers(init.headers ?? {});
const server = createServer((req, res) => { expect(headers.get("authorization")).toBe("Bearer registry-token");
const auth = String(req.headers.authorization ?? "").trim(); expect(getBridgeAuthForPort).toHaveBeenCalledWith(port);
if (auth !== "Bearer registry-token") {
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true }));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const port = (server.address() as AddressInfo).port;
setBridgeAuthForPort(port, { token: "registry-token" });
try {
const { fetchBrowserJson } = await import("./client-fetch.js");
const result = await fetchBrowserJson<{ ok: boolean }>(`http://127.0.0.1:${port}/`, {
timeoutMs: 2000,
});
expect(result.ok).toBe(true);
} finally {
deleteBridgeAuthForPort(port);
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}); });
}); });
+26 -4
View File
@@ -8,6 +8,12 @@ import {
} from "./control-service.js"; } from "./control-service.js";
import { createBrowserRouteDispatcher } from "./routes/dispatcher.js"; import { createBrowserRouteDispatcher } from "./routes/dispatcher.js";
type LoopbackBrowserAuthDeps = {
loadConfig: typeof loadConfig;
resolveBrowserControlAuth: typeof resolveBrowserControlAuth;
getBridgeAuthForPort: typeof getBridgeAuthForPort;
};
function isAbsoluteHttp(url: string): boolean { function isAbsoluteHttp(url: string): boolean {
return /^https?:\/\//i.test(url.trim()); return /^https?:\/\//i.test(url.trim());
} }
@@ -21,9 +27,10 @@ function isLoopbackHttpUrl(url: string): boolean {
} }
} }
function withLoopbackBrowserAuth( function withLoopbackBrowserAuthImpl(
url: string, url: string,
init: (RequestInit & { timeoutMs?: number }) | undefined, init: (RequestInit & { timeoutMs?: number }) | undefined,
deps: LoopbackBrowserAuthDeps,
): RequestInit & { timeoutMs?: number } { ): RequestInit & { timeoutMs?: number } {
const headers = new Headers(init?.headers ?? {}); const headers = new Headers(init?.headers ?? {});
if (headers.has("authorization") || headers.has("x-openclaw-password")) { if (headers.has("authorization") || headers.has("x-openclaw-password")) {
@@ -34,8 +41,8 @@ function withLoopbackBrowserAuth(
} }
try { try {
const cfg = loadConfig(); const cfg = deps.loadConfig();
const auth = resolveBrowserControlAuth(cfg); const auth = deps.resolveBrowserControlAuth(cfg);
if (auth.token) { if (auth.token) {
headers.set("Authorization", `Bearer ${auth.token}`); headers.set("Authorization", `Bearer ${auth.token}`);
return { ...init, headers }; return { ...init, headers };
@@ -58,7 +65,7 @@ function withLoopbackBrowserAuth(
: parsed.protocol === "https:" : parsed.protocol === "https:"
? 443 ? 443
: 80; : 80;
const bridgeAuth = getBridgeAuthForPort(port); const bridgeAuth = deps.getBridgeAuthForPort(port);
if (bridgeAuth?.token) { if (bridgeAuth?.token) {
headers.set("Authorization", `Bearer ${bridgeAuth.token}`); headers.set("Authorization", `Bearer ${bridgeAuth.token}`);
} else if (bridgeAuth?.password) { } else if (bridgeAuth?.password) {
@@ -71,6 +78,17 @@ function withLoopbackBrowserAuth(
return { ...init, headers }; return { ...init, headers };
} }
function withLoopbackBrowserAuth(
url: string,
init: (RequestInit & { timeoutMs?: number }) | undefined,
): RequestInit & { timeoutMs?: number } {
return withLoopbackBrowserAuthImpl(url, init, {
loadConfig,
resolveBrowserControlAuth,
getBridgeAuthForPort,
});
}
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error { function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
const hint = isAbsoluteHttp(url) const hint = isAbsoluteHttp(url)
? "If this is a sandboxed session, ensure the sandbox browser is running and try again." ? "If this is a sandboxed session, ensure the sandbox browser is running and try again."
@@ -215,3 +233,7 @@ export async function fetchBrowserJson<T>(
throw enhanceBrowserFetchError(url, err, timeoutMs); throw enhanceBrowserFetchError(url, err, timeoutMs);
} }
} }
export const __test = {
withLoopbackBrowserAuth: withLoopbackBrowserAuthImpl,
};
@@ -17,30 +17,26 @@ function buildConfig() {
}; };
} }
vi.mock("../config/config.js", async (importOriginal) => { vi.mock("../config/config.js", () => ({
const actual = await importOriginal<typeof import("../config/config.js")>(); createConfigIO: () => ({
return {
...actual,
createConfigIO: () => ({
loadConfig: () => {
// Always return fresh config for createConfigIO to simulate fresh disk read
return buildConfig();
},
}),
loadConfig: () => { loadConfig: () => {
// simulate stale loadConfig that doesn't see updates unless cache cleared // Always return fresh config for createConfigIO to simulate fresh disk read
if (!cachedConfig) { return buildConfig();
cachedConfig = buildConfig();
}
return cachedConfig;
}, },
clearConfigCache: vi.fn(() => { }),
// Clear the simulated cache loadConfig: () => {
cachedConfig = null; // simulate stale loadConfig that doesn't see updates unless cache cleared
}), if (!cachedConfig) {
writeConfigFile: vi.fn(async () => {}), cachedConfig = buildConfig();
}; }
}); return cachedConfig;
},
clearConfigCache: vi.fn(() => {
// Clear the simulated cache
cachedConfig = null;
}),
writeConfigFile: vi.fn(async () => {}),
}));
vi.mock("./chrome.js", () => ({ vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => false), isChromeCdpReady: vi.fn(async () => false),
@@ -72,8 +68,34 @@ vi.mock("../media/store.js", () => ({
})); }));
describe("server-context hot-reload profiles", () => { describe("server-context hot-reload profiles", () => {
let modulesPromise: Promise<{
createBrowserRouteContext: typeof import("./server-context.js").createBrowserRouteContext;
resolveBrowserConfig: typeof import("./config.js").resolveBrowserConfig;
loadConfig: typeof import("../config/config.js").loadConfig;
clearConfigCache: typeof import("../config/config.js").clearConfigCache;
}> | null = null;
const getModules = async () => {
if (!modulesPromise) {
modulesPromise = (async () => {
// Avoid parallel imports here; Vitest mock factories use async importOriginal
// and parallel loading can observe partially-initialized modules.
const configMod = await import("../config/config.js");
const config = await import("./config.js");
const serverContext = await import("./server-context.js");
return {
createBrowserRouteContext: serverContext.createBrowserRouteContext,
resolveBrowserConfig: config.resolveBrowserConfig,
loadConfig: configMod.loadConfig,
clearConfigCache: configMod.clearConfigCache,
};
})();
}
return await modulesPromise;
};
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.clearAllMocks();
cfgProfiles = { cfgProfiles = {
openclaw: { cdpPort: 18800, color: "#FF4500" }, openclaw: { cdpPort: 18800, color: "#FF4500" },
}; };
@@ -81,11 +103,10 @@ describe("server-context hot-reload profiles", () => {
}); });
it("forProfile hot-reloads newly added profiles from config", async () => { it("forProfile hot-reloads newly added profiles from config", async () => {
// Start with only openclaw profile const { createBrowserRouteContext, resolveBrowserConfig, loadConfig, clearConfigCache } =
const { createBrowserRouteContext } = await import("./server-context.js"); await getModules();
const { resolveBrowserConfig } = await import("./config.js");
const { loadConfig } = await import("../config/config.js");
// Start with only openclaw profile
// 1. Prime the cache by calling loadConfig() first // 1. Prime the cache by calling loadConfig() first
const cfg = loadConfig(); const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg); const resolved = resolveBrowserConfig(cfg.browser, cfg);
@@ -129,14 +150,11 @@ describe("server-context hot-reload profiles", () => {
expect(stillStaleCfg.browser.profiles.desktop).toBeUndefined(); expect(stillStaleCfg.browser.profiles.desktop).toBeUndefined();
// Verify clearConfigCache was not called // Verify clearConfigCache was not called
const { clearConfigCache } = await import("../config/config.js");
expect(clearConfigCache).not.toHaveBeenCalled(); expect(clearConfigCache).not.toHaveBeenCalled();
}); });
it("forProfile still throws for profiles that don't exist in fresh config", async () => { it("forProfile still throws for profiles that don't exist in fresh config", async () => {
const { createBrowserRouteContext } = await import("./server-context.js"); const { createBrowserRouteContext, resolveBrowserConfig, loadConfig } = await getModules();
const { resolveBrowserConfig } = await import("./config.js");
const { loadConfig } = await import("../config/config.js");
const cfg = loadConfig(); const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg); const resolved = resolveBrowserConfig(cfg.browser, cfg);
@@ -157,9 +175,7 @@ describe("server-context hot-reload profiles", () => {
}); });
it("forProfile refreshes existing profile config after loadConfig cache updates", async () => { it("forProfile refreshes existing profile config after loadConfig cache updates", async () => {
const { createBrowserRouteContext } = await import("./server-context.js"); const { createBrowserRouteContext, resolveBrowserConfig, loadConfig } = await getModules();
const { resolveBrowserConfig } = await import("./config.js");
const { loadConfig } = await import("../config/config.js");
const cfg = loadConfig(); const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg); const resolved = resolveBrowserConfig(cfg.browser, cfg);
@@ -187,9 +203,7 @@ describe("server-context hot-reload profiles", () => {
}); });
it("listProfiles refreshes config before enumerating profiles", async () => { it("listProfiles refreshes config before enumerating profiles", async () => {
const { createBrowserRouteContext } = await import("./server-context.js"); const { createBrowserRouteContext, resolveBrowserConfig, loadConfig } = await getModules();
const { resolveBrowserConfig } = await import("./config.js");
const { loadConfig } = await import("../config/config.js");
const cfg = loadConfig(); const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg); const resolved = resolveBrowserConfig(cfg.browser, cfg);
@@ -1,91 +1,46 @@
import { createServer, type AddressInfo } from "node:net"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { fetch as realFetch } from "undici"; import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isAuthorizedBrowserRequest } from "./http-auth.js";
let testPort = 0; let server: ReturnType<typeof createServer> | null = null;
let prevGatewayPort: string | undefined; let port = 0;
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
gateway: {
auth: {
token: "browser-control-secret",
},
},
browser: {
enabled: true,
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
};
});
vi.mock("./routes/index.js", () => ({
registerBrowserRoutes(app: {
get: (
path: string,
handler: (req: unknown, res: { json: (body: unknown) => void }) => void,
) => void;
}) {
app.get("/", (_req, res) => {
res.json({ ok: true });
});
},
}));
vi.mock("./server-context.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./server-context.js")>();
return {
...actual,
createBrowserRouteContext: vi.fn(() => ({
forProfile: vi.fn(() => ({
stopRunningBrowser: vi.fn(async () => {}),
})),
})),
};
});
describe("browser control HTTP auth", () => { describe("browser control HTTP auth", () => {
beforeEach(async () => { beforeEach(async () => {
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT; server = createServer((req: IncomingMessage, res: ServerResponse) => {
if (!isAuthorizedBrowserRequest(req, { token: "browser-control-secret" })) {
const probe = createServer(); res.statusCode = 401;
await new Promise<void>((resolve, reject) => { res.setHeader("Content-Type", "text/plain; charset=utf-8");
probe.once("error", reject); res.end("Unauthorized");
probe.listen(0, "127.0.0.1", () => resolve()); return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true }));
}); });
const addr = probe.address() as AddressInfo; await new Promise<void>((resolve, reject) => {
testPort = addr.port; server?.once("error", reject);
await new Promise<void>((resolve) => probe.close(() => resolve())); server?.listen(0, "127.0.0.1", () => resolve());
});
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2); const addr = server.address();
if (!addr || typeof addr === "string") {
throw new Error("server address missing");
}
port = addr.port;
}); });
afterEach(async () => { afterEach(async () => {
vi.unstubAllGlobals(); const current = server;
vi.restoreAllMocks(); server = null;
if (prevGatewayPort === undefined) { if (!current) {
delete process.env.OPENCLAW_GATEWAY_PORT; return;
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
} }
await new Promise<void>((resolve) => current.close(() => resolve()));
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
}); });
it("requires bearer auth for standalone browser HTTP routes", async () => { it("requires bearer auth for standalone browser HTTP routes", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js"); const base = `http://127.0.0.1:${port}`;
const started = await startBrowserControlServerFromConfig();
expect(started?.port).toBe(testPort);
const base = `http://127.0.0.1:${testPort}`;
const missingAuth = await realFetch(`${base}/`); const missingAuth = await realFetch(`${base}/`);
expect(missingAuth.status).toBe(401); expect(missingAuth.status).toBe(401);
+119 -85
View File
@@ -195,31 +195,132 @@ function validateConfigObjectWithPluginsBase(
const config = base.config; const config = base.config;
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const warnings: ConfigValidationIssue[] = []; const warnings: ConfigValidationIssue[] = [];
const pluginsConfig = config.plugins; const hasExplicitPluginsConfig =
const normalizedPlugins = normalizePluginsConfig(pluginsConfig); isRecord(raw) && Object.prototype.hasOwnProperty.call(raw, "plugins");
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); type RegistryInfo = {
const registry = loadPluginManifestRegistry({ registry: ReturnType<typeof loadPluginManifestRegistry>;
config, knownIds: Set<string>;
workspaceDir: workspaceDir ?? undefined, normalizedPlugins: ReturnType<typeof normalizePluginsConfig>;
}); };
const knownIds = new Set(registry.plugins.map((record) => record.id)); let registryInfo: RegistryInfo | null = null;
for (const diag of registry.diagnostics) { const ensureRegistry = (): RegistryInfo => {
let path = diag.pluginId ? `plugins.entries.${diag.pluginId}` : "plugins"; if (registryInfo) {
if (!diag.pluginId && diag.message.includes("plugin path not found")) { return registryInfo;
path = "plugins.load.paths";
} }
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
const message = `${pluginLabel}: ${diag.message}`; const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
if (diag.level === "error") { const registry = loadPluginManifestRegistry({
issues.push({ path, message }); config,
} else { workspaceDir: workspaceDir ?? undefined,
warnings.push({ path, message }); });
const knownIds = new Set(registry.plugins.map((record) => record.id));
const normalizedPlugins = normalizePluginsConfig(config.plugins);
for (const diag of registry.diagnostics) {
let path = diag.pluginId ? `plugins.entries.${diag.pluginId}` : "plugins";
if (!diag.pluginId && diag.message.includes("plugin path not found")) {
path = "plugins.load.paths";
}
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
const message = `${pluginLabel}: ${diag.message}`;
if (diag.level === "error") {
issues.push({ path, message });
} else {
warnings.push({ path, message });
}
}
registryInfo = { registry, knownIds, normalizedPlugins };
return registryInfo;
};
const allowedChannels = new Set<string>(["defaults", ...CHANNEL_IDS]);
if (config.channels && isRecord(config.channels)) {
for (const key of Object.keys(config.channels)) {
const trimmed = key.trim();
if (!trimmed) {
continue;
}
if (!allowedChannels.has(trimmed)) {
const { registry } = ensureRegistry();
for (const record of registry.plugins) {
for (const channelId of record.channels) {
allowedChannels.add(channelId);
}
}
}
if (!allowedChannels.has(trimmed)) {
issues.push({
path: `channels.${trimmed}`,
message: `unknown channel id: ${trimmed}`,
});
}
} }
} }
const heartbeatChannelIds = new Set<string>();
for (const channelId of CHANNEL_IDS) {
heartbeatChannelIds.add(channelId.toLowerCase());
}
const validateHeartbeatTarget = (target: string | undefined, path: string) => {
if (typeof target !== "string") {
return;
}
const trimmed = target.trim();
if (!trimmed) {
issues.push({ path, message: "heartbeat target must not be empty" });
return;
}
const normalized = trimmed.toLowerCase();
if (normalized === "last" || normalized === "none") {
return;
}
if (normalizeChatChannelId(trimmed)) {
return;
}
if (!heartbeatChannelIds.has(normalized)) {
const { registry } = ensureRegistry();
for (const record of registry.plugins) {
for (const channelId of record.channels) {
const pluginChannel = channelId.trim();
if (pluginChannel) {
heartbeatChannelIds.add(pluginChannel.toLowerCase());
}
}
}
}
if (heartbeatChannelIds.has(normalized)) {
return;
}
issues.push({ path, message: `unknown heartbeat target: ${target}` });
};
validateHeartbeatTarget(
config.agents?.defaults?.heartbeat?.target,
"agents.defaults.heartbeat.target",
);
if (Array.isArray(config.agents?.list)) {
for (const [index, entry] of config.agents.list.entries()) {
validateHeartbeatTarget(entry?.heartbeat?.target, `agents.list.${index}.heartbeat.target`);
}
}
if (!hasExplicitPluginsConfig) {
if (issues.length > 0) {
return { ok: false, issues, warnings };
}
return { ok: true, config, warnings };
}
const { registry, knownIds, normalizedPlugins } = ensureRegistry();
const pluginsConfig = config.plugins;
const entries = pluginsConfig?.entries; const entries = pluginsConfig?.entries;
if (entries && isRecord(entries)) { if (entries && isRecord(entries)) {
for (const pluginId of Object.keys(entries)) { for (const pluginId of Object.keys(entries)) {
@@ -266,73 +367,6 @@ function validateConfigObjectWithPluginsBase(
}); });
} }
const allowedChannels = new Set<string>(["defaults", ...CHANNEL_IDS]);
for (const record of registry.plugins) {
for (const channelId of record.channels) {
allowedChannels.add(channelId);
}
}
if (config.channels && isRecord(config.channels)) {
for (const key of Object.keys(config.channels)) {
const trimmed = key.trim();
if (!trimmed) {
continue;
}
if (!allowedChannels.has(trimmed)) {
issues.push({
path: `channels.${trimmed}`,
message: `unknown channel id: ${trimmed}`,
});
}
}
}
const heartbeatChannelIds = new Set<string>();
for (const channelId of CHANNEL_IDS) {
heartbeatChannelIds.add(channelId.toLowerCase());
}
for (const record of registry.plugins) {
for (const channelId of record.channels) {
const trimmed = channelId.trim();
if (trimmed) {
heartbeatChannelIds.add(trimmed.toLowerCase());
}
}
}
const validateHeartbeatTarget = (target: string | undefined, path: string) => {
if (typeof target !== "string") {
return;
}
const trimmed = target.trim();
if (!trimmed) {
issues.push({ path, message: "heartbeat target must not be empty" });
return;
}
const normalized = trimmed.toLowerCase();
if (normalized === "last" || normalized === "none") {
return;
}
if (normalizeChatChannelId(trimmed)) {
return;
}
if (heartbeatChannelIds.has(normalized)) {
return;
}
issues.push({ path, message: `unknown heartbeat target: ${target}` });
};
validateHeartbeatTarget(
config.agents?.defaults?.heartbeat?.target,
"agents.defaults.heartbeat.target",
);
if (Array.isArray(config.agents?.list)) {
for (const [index, entry] of config.agents.list.entries()) {
validateHeartbeatTarget(entry?.heartbeat?.target, `agents.list.${index}.heartbeat.target`);
}
}
let selectedMemoryPluginId: string | null = null; let selectedMemoryPluginId: string | null = null;
const seenPlugins = new Set<string>(); const seenPlugins = new Set<string>();
for (const record of registry.plugins) { for (const record of registry.plugins) {
+3
View File
@@ -2,6 +2,9 @@ import { afterAll, afterEach, beforeEach, vi } from "vitest";
// Ensure Vitest environment is properly set // Ensure Vitest environment is properly set
process.env.VITEST = "true"; process.env.VITEST = "true";
// Config validation walks plugin manifests; keep an aggressive cache in tests to avoid
// repeated filesystem discovery across suites/workers.
process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ??= "60000";
// Vitest vm forks can load transitive lockfile helpers many times per worker. // Vitest vm forks can load transitive lockfile helpers many times per worker.
// Raise listener budget to avoid noisy MaxListeners warnings and warning-stack overhead. // Raise listener budget to avoid noisy MaxListeners warnings and warning-stack overhead.
const TEST_PROCESS_MAX_LISTENERS = 128; const TEST_PROCESS_MAX_LISTENERS = 128;