perf(test): speed up suites and reduce fs churn

This commit is contained in:
Peter Steinberger
2026-02-15 19:18:49 +00:00
parent 8fdde0429e
commit 92f8c0fac3
32 changed files with 1793 additions and 1398 deletions

View File

@@ -30,7 +30,7 @@ const unitIsolatedFilesRaw = [
"src/browser/server-context.remote-tab-ops.test.ts",
"src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts",
// Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage.
"src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts",
"src/imessage/monitor.shutdown.unhandled-rejection.test.ts",
];
const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file));

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const noop = () => {};
let lifecycleHandler:
@@ -39,16 +39,24 @@ vi.mock("./subagent-registry.store.js", () => ({
}));
describe("subagent registry steer restarts", () => {
let mod: typeof import("./subagent-registry.js");
beforeAll(async () => {
mod = await import("./subagent-registry.js");
});
const flushAnnounce = async () => {
await new Promise<void>((resolve) => setImmediate(resolve));
};
afterEach(async () => {
announceSpy.mockClear();
announceSpy.mockReset();
announceSpy.mockResolvedValue(true);
lifecycleHandler = undefined;
const mod = await import("./subagent-registry.js");
mod.resetSubagentRegistryForTests({ persist: false });
});
it("suppresses announce for interrupted runs and only announces the replacement run", async () => {
const mod = await import("./subagent-registry.js");
mod.registerSubagentRun({
runId: "run-old",
childSessionKey: "agent:main:subagent:steer",
@@ -70,7 +78,7 @@ describe("subagent registry steer restarts", () => {
data: { phase: "end" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await flushAnnounce();
expect(announceSpy).not.toHaveBeenCalled();
const replaced = mod.replaceSubagentRunAfterSteer({
@@ -90,7 +98,7 @@ describe("subagent registry steer restarts", () => {
data: { phase: "end" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await flushAnnounce();
expect(announceSpy).toHaveBeenCalledTimes(1);
const announce = announceSpy.mock.calls[0]?.[0] as { childRunId?: string };
@@ -98,8 +106,6 @@ describe("subagent registry steer restarts", () => {
});
it("restores announce for a finished run when steer replacement dispatch fails", async () => {
const mod = await import("./subagent-registry.js");
mod.registerSubagentRun({
runId: "run-failed-restart",
childSessionKey: "agent:main:subagent:failed-restart",
@@ -117,11 +123,11 @@ describe("subagent registry steer restarts", () => {
data: { phase: "end" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await flushAnnounce();
expect(announceSpy).not.toHaveBeenCalled();
expect(mod.clearSubagentRunSteerRestart("run-failed-restart")).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
await flushAnnounce();
expect(announceSpy).toHaveBeenCalledTimes(1);
const announce = announceSpy.mock.calls[0]?.[0] as { childRunId?: string };
@@ -129,7 +135,6 @@ describe("subagent registry steer restarts", () => {
});
it("marks killed runs terminated and inactive", async () => {
const mod = await import("./subagent-registry.js");
const childSessionKey = "agent:main:subagent:killed";
mod.registerSubagentRun({
@@ -156,7 +161,6 @@ describe("subagent registry steer restarts", () => {
});
it("retries deferred parent cleanup after a descendant announces", async () => {
const mod = await import("./subagent-registry.js");
let parentAttempts = 0;
announceSpy.mockImplementation(async (params: unknown) => {
const typed = params as { childRunId?: string };
@@ -189,14 +193,14 @@ describe("subagent registry steer restarts", () => {
runId: "run-parent",
data: { phase: "end" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await flushAnnounce();
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-child",
data: { phase: "end" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await flushAnnounce();
const childRunIds = announceSpy.mock.calls.map(
(call) => (call[0] as { childRunId?: string }).childRunId,

View File

@@ -1,12 +1,31 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { loadExtraBootstrapFiles } from "./workspace.js";
describe("loadExtraBootstrapFiles", () => {
let fixtureRoot = "";
let fixtureCount = 0;
const createWorkspaceDir = async (prefix: string) => {
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-extra-bootstrap-"));
});
afterAll(async () => {
if (fixtureRoot) {
await fs.rm(fixtureRoot, { recursive: true, force: true });
}
});
it("loads recognized bootstrap files from glob patterns", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-extra-bootstrap-glob-");
const workspaceDir = await createWorkspaceDir("glob");
const packageDir = path.join(workspaceDir, "packages", "core");
await fs.mkdir(packageDir, { recursive: true });
await fs.writeFile(path.join(packageDir, "TOOLS.md"), "tools", "utf-8");
@@ -20,7 +39,7 @@ describe("loadExtraBootstrapFiles", () => {
});
it("keeps path-traversal attempts outside workspace excluded", async () => {
const rootDir = await makeTempWorkspace("openclaw-extra-bootstrap-root-");
const rootDir = await createWorkspaceDir("root");
const workspaceDir = path.join(rootDir, "workspace");
const outsideDir = path.join(rootDir, "outside");
await fs.mkdir(workspaceDir, { recursive: true });
@@ -37,7 +56,7 @@ describe("loadExtraBootstrapFiles", () => {
return;
}
const rootDir = await makeTempWorkspace("openclaw-extra-bootstrap-symlink-");
const rootDir = await createWorkspaceDir("symlink");
const realWorkspace = path.join(rootDir, "real-workspace");
const linkedWorkspace = path.join(rootDir, "linked-workspace");
await fs.mkdir(realWorkspace, { recursive: true });

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
decorateOpenClawProfile,
ensureProfileCleanExit,
@@ -23,112 +23,111 @@ async function readJson(filePath: string): Promise<Record<string, unknown>> {
}
describe("browser chrome profile decoration", () => {
let fixtureRoot = "";
let fixtureCount = 0;
const createUserDataDir = async () => {
const dir = path.join(fixtureRoot, `profile-${fixtureCount++}`);
await fsp.mkdir(dir, { recursive: true });
return dir;
};
beforeAll(async () => {
fixtureRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-suite-"));
});
afterAll(async () => {
if (fixtureRoot) {
await fsp.rm(fixtureRoot, { recursive: true, force: true });
}
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("writes expected name + signed ARGB seed to Chrome prefs", async () => {
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-"));
try {
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
const userDataDir = await createUserDataDir();
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0;
const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0;
const localState = await readJson(path.join(userDataDir, "Local State"));
const profile = localState.profile as Record<string, unknown>;
const infoCache = profile.info_cache as Record<string, unknown>;
const def = infoCache.Default as Record<string, unknown>;
const localState = await readJson(path.join(userDataDir, "Local State"));
const profile = localState.profile as Record<string, unknown>;
const infoCache = profile.info_cache as Record<string, unknown>;
const def = infoCache.Default as Record<string, unknown>;
expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
expect(def.shortcut_name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
expect(def.profile_color_seed).toBe(expectedSignedArgb);
expect(def.profile_highlight_color).toBe(expectedSignedArgb);
expect(def.default_avatar_fill_color).toBe(expectedSignedArgb);
expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb);
expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
expect(def.shortcut_name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
expect(def.profile_color_seed).toBe(expectedSignedArgb);
expect(def.profile_highlight_color).toBe(expectedSignedArgb);
expect(def.default_avatar_fill_color).toBe(expectedSignedArgb);
expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb);
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
const browser = prefs.browser as Record<string, unknown>;
const theme = browser.theme as Record<string, unknown>;
const autogenerated = prefs.autogenerated as Record<string, unknown>;
const autogeneratedTheme = autogenerated.theme as Record<string, unknown>;
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
const browser = prefs.browser as Record<string, unknown>;
const theme = browser.theme as Record<string, unknown>;
const autogenerated = prefs.autogenerated as Record<string, unknown>;
const autogeneratedTheme = autogenerated.theme as Record<string, unknown>;
expect(theme.user_color2).toBe(expectedSignedArgb);
expect(autogeneratedTheme.color).toBe(expectedSignedArgb);
expect(theme.user_color2).toBe(expectedSignedArgb);
expect(autogeneratedTheme.color).toBe(expectedSignedArgb);
const marker = await fsp.readFile(
path.join(userDataDir, ".openclaw-profile-decorated"),
"utf-8",
);
expect(marker.trim()).toMatch(/^\d+$/);
} finally {
await fsp.rm(userDataDir, { recursive: true, force: true });
}
const marker = await fsp.readFile(
path.join(userDataDir, ".openclaw-profile-decorated"),
"utf-8",
);
expect(marker.trim()).toMatch(/^\d+$/);
});
it("best-effort writes name when color is invalid", async () => {
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-"));
try {
decorateOpenClawProfile(userDataDir, { color: "lobster-orange" });
const localState = await readJson(path.join(userDataDir, "Local State"));
const profile = localState.profile as Record<string, unknown>;
const infoCache = profile.info_cache as Record<string, unknown>;
const def = infoCache.Default as Record<string, unknown>;
const userDataDir = await createUserDataDir();
decorateOpenClawProfile(userDataDir, { color: "lobster-orange" });
const localState = await readJson(path.join(userDataDir, "Local State"));
const profile = localState.profile as Record<string, unknown>;
const infoCache = profile.info_cache as Record<string, unknown>;
const def = infoCache.Default as Record<string, unknown>;
expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
expect(def.profile_color_seed).toBeUndefined();
} finally {
await fsp.rm(userDataDir, { recursive: true, force: true });
}
expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
expect(def.profile_color_seed).toBeUndefined();
});
it("recovers from missing/invalid preference files", async () => {
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-"));
try {
await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true });
await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON
await fsp.writeFile(
path.join(userDataDir, "Default", "Preferences"),
"[]", // valid JSON but wrong shape
"utf-8",
);
const userDataDir = await createUserDataDir();
await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true });
await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON
await fsp.writeFile(
path.join(userDataDir, "Default", "Preferences"),
"[]", // valid JSON but wrong shape
"utf-8",
);
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
const localState = await readJson(path.join(userDataDir, "Local State"));
expect(typeof localState.profile).toBe("object");
const localState = await readJson(path.join(userDataDir, "Local State"));
expect(typeof localState.profile).toBe("object");
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
expect(typeof prefs.profile).toBe("object");
} finally {
await fsp.rm(userDataDir, { recursive: true, force: true });
}
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
expect(typeof prefs.profile).toBe("object");
});
it("writes clean exit prefs to avoid restore prompts", async () => {
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-"));
try {
ensureProfileCleanExit(userDataDir);
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
expect(prefs.exit_type).toBe("Normal");
expect(prefs.exited_cleanly).toBe(true);
} finally {
await fsp.rm(userDataDir, { recursive: true, force: true });
}
const userDataDir = await createUserDataDir();
ensureProfileCleanExit(userDataDir);
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
expect(prefs.exit_type).toBe("Normal");
expect(prefs.exited_cleanly).toBe(true);
});
it("is idempotent when rerun on an existing profile", async () => {
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-test-"));
try {
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
const userDataDir = await createUserDataDir();
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
const profile = prefs.profile as Record<string, unknown>;
expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
} finally {
await fsp.rm(userDataDir, { recursive: true, force: true });
}
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
const profile = prefs.profile as Record<string, unknown>;
expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
});
});

View File

@@ -1,7 +1,5 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const copyToClipboard = vi.fn();
const runtime = {
@@ -10,6 +8,91 @@ const runtime = {
exit: vi.fn(),
};
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
const state = vi.hoisted(() => ({
entries: new Map<string, FakeFsEntry>(),
counter: 0,
}));
const abs = (p: string) => path.resolve(p);
function setFile(p: string, content = "") {
const resolved = abs(p);
state.entries.set(resolved, { kind: "file", content });
setDir(path.dirname(resolved));
}
function setDir(p: string) {
const resolved = abs(p);
if (!state.entries.has(resolved)) {
state.entries.set(resolved, { kind: "dir" });
}
}
function copyTree(src: string, dest: string) {
const srcAbs = abs(src);
const destAbs = abs(dest);
const srcPrefix = `${srcAbs}${path.sep}`;
for (const [key, entry] of state.entries.entries()) {
if (key === srcAbs || key.startsWith(srcPrefix)) {
const rel = key === srcAbs ? "" : key.slice(srcPrefix.length);
const next = rel ? path.join(destAbs, rel) : destAbs;
state.entries.set(next, entry);
}
}
}
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const pathMod = await import("node:path");
const absInMock = (p: string) => pathMod.resolve(p);
const wrapped = {
...actual,
existsSync: (p: string) => state.entries.has(absInMock(p)),
mkdirSync: (p: string, _opts?: unknown) => {
setDir(p);
},
writeFileSync: (p: string, content: string) => {
setFile(p, content);
},
renameSync: (from: string, to: string) => {
const fromAbs = absInMock(from);
const toAbs = absInMock(to);
const entry = state.entries.get(fromAbs);
if (!entry) {
throw new Error(`ENOENT: no such file or directory, rename '${from}' -> '${to}'`);
}
state.entries.delete(fromAbs);
state.entries.set(toAbs, entry);
},
rmSync: (p: string) => {
const root = absInMock(p);
const prefix = `${root}${pathMod.sep}`;
const keys = Array.from(state.entries.keys());
for (const key of keys) {
if (key === root || key.startsWith(prefix)) {
state.entries.delete(key);
}
}
},
mkdtempSync: (prefix: string) => {
const dir = `${prefix}${state.counter++}`;
setDir(dir);
return dir;
},
promises: {
...actual.promises,
cp: async (src: string, dest: string, _opts?: unknown) => {
copyTree(src, dest);
},
},
};
return { ...wrapped, default: wrapped };
});
vi.mock("../infra/clipboard.js", () => ({
copyToClipboard,
}));
@@ -18,86 +101,83 @@ vi.mock("../runtime.js", () => ({
defaultRuntime: runtime,
}));
let resolveBundledExtensionRootDir: typeof import("./browser-cli-extension.js").resolveBundledExtensionRootDir;
let installChromeExtension: typeof import("./browser-cli-extension.js").installChromeExtension;
let registerBrowserExtensionCommands: typeof import("./browser-cli-extension.js").registerBrowserExtensionCommands;
beforeAll(async () => {
({ resolveBundledExtensionRootDir, installChromeExtension, registerBrowserExtensionCommands } =
await import("./browser-cli-extension.js"));
});
beforeEach(() => {
state.entries.clear();
state.counter = 0;
copyToClipboard.mockReset();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
vi.clearAllMocks();
});
function writeManifest(dir: string) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify({ manifest_version: 3 }));
setDir(dir);
setFile(path.join(dir, "manifest.json"), JSON.stringify({ manifest_version: 3 }));
}
describe("bundled extension resolver", () => {
it("walks up to find the assets directory", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-root-"));
describe("bundled extension resolver (fs-mocked)", () => {
it("walks up to find the assets directory", () => {
const root = abs("/tmp/openclaw-ext-root");
const here = path.join(root, "dist", "cli");
const assets = path.join(root, "assets", "chrome-extension");
try {
writeManifest(assets);
fs.mkdirSync(here, { recursive: true });
writeManifest(assets);
setDir(here);
const { resolveBundledExtensionRootDir } = await import("./browser-cli-extension.js");
expect(resolveBundledExtensionRootDir(here)).toBe(assets);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
expect(resolveBundledExtensionRootDir(here)).toBe(assets);
});
it("prefers the nearest assets directory", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-root-"));
it("prefers the nearest assets directory", () => {
const root = abs("/tmp/openclaw-ext-root-nearest");
const here = path.join(root, "dist", "cli");
const distAssets = path.join(root, "dist", "assets", "chrome-extension");
const rootAssets = path.join(root, "assets", "chrome-extension");
try {
writeManifest(distAssets);
writeManifest(rootAssets);
fs.mkdirSync(here, { recursive: true });
writeManifest(distAssets);
writeManifest(rootAssets);
setDir(here);
const { resolveBundledExtensionRootDir } = await import("./browser-cli-extension.js");
expect(resolveBundledExtensionRootDir(here)).toBe(distAssets);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
expect(resolveBundledExtensionRootDir(here)).toBe(distAssets);
});
});
describe("browser extension install", () => {
describe("browser extension install (fs-mocked)", () => {
it("installs into the state dir (never node_modules)", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-"));
const tmp = abs("/tmp/openclaw-ext-install");
const sourceDir = path.join(tmp, "source-ext");
writeManifest(sourceDir);
setFile(path.join(sourceDir, "test.txt"), "ok");
try {
const { installChromeExtension } = await import("./browser-cli-extension.js");
// Keep this test hermetic + fast: use a tiny fixture instead of copying the
// full repo assets tree.
const sourceDir = path.join(tmp, "source-ext");
writeManifest(sourceDir);
fs.writeFileSync(path.join(sourceDir, "test.txt"), "ok");
const result = await installChromeExtension({ stateDir: tmp, sourceDir });
const result = await installChromeExtension({ stateDir: tmp, sourceDir });
expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension"));
expect(fs.existsSync(path.join(result.path, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(result.path, "test.txt"))).toBe(true);
expect(result.path.includes("node_modules")).toBe(false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
expect(result.path).toBe(path.join(tmp, "browser", "chrome-extension"));
expect(state.entries.has(abs(path.join(result.path, "manifest.json")))).toBe(true);
expect(state.entries.has(abs(path.join(result.path, "test.txt")))).toBe(true);
expect(result.path.includes("node_modules")).toBe(false);
});
it("copies extension path to clipboard", async () => {
const prev = process.env.OPENCLAW_STATE_DIR;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ext-path-"));
const tmp = abs("/tmp/openclaw-ext-path");
process.env.OPENCLAW_STATE_DIR = tmp;
try {
copyToClipboard.mockReset();
copyToClipboard.mockResolvedValue(true);
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
const dir = path.join(tmp, "browser", "chrome-extension");
writeManifest(dir);
const { Command } = await import("commander");
const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js");
const program = new Command();
const browser = program.command("browser").option("--json", false);
@@ -107,7 +187,6 @@ describe("browser extension install", () => {
);
await program.parseAsync(["browser", "extension", "path"], { from: "user" });
expect(copyToClipboard).toHaveBeenCalledWith(dir);
} finally {
if (prev === undefined) {

View File

@@ -64,11 +64,27 @@ describe("runGatewayLoop", () => {
const closeFirst = vi.fn(async () => {});
const closeSecond = vi.fn(async () => {});
const start = vi
.fn<StartServer>()
.mockResolvedValueOnce({ close: closeFirst })
.mockResolvedValueOnce({ close: closeSecond })
.mockRejectedValueOnce(new Error("stop-loop"));
const start = vi.fn<StartServer>();
let resolveFirst: (() => void) | null = null;
const startedFirst = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
start.mockImplementationOnce(async () => {
resolveFirst?.();
return { close: closeFirst };
});
let resolveSecond: (() => void) | null = null;
const startedSecond = new Promise<void>((resolve) => {
resolveSecond = resolve;
});
start.mockImplementationOnce(async () => {
resolveSecond?.();
return { close: closeSecond };
});
start.mockRejectedValueOnce(new Error("stop-loop"));
const beforeSigterm = new Set(
process.listeners("SIGTERM") as Array<(...args: unknown[]) => void>,
@@ -80,25 +96,24 @@ describe("runGatewayLoop", () => {
process.listeners("SIGUSR1") as Array<(...args: unknown[]) => void>,
);
const loopPromise = import("./run-loop.js").then(({ runGatewayLoop }) =>
runGatewayLoop({
start,
runtime: {
exit: vi.fn(),
} as { exit: (code: number) => never },
}),
);
const { runGatewayLoop } = await import("./run-loop.js");
const loopPromise = runGatewayLoop({
start,
runtime: {
exit: vi.fn(),
} as { exit: (code: number) => never },
});
try {
await vi.waitFor(() => {
expect(start).toHaveBeenCalledTimes(1);
});
await startedFirst;
expect(start).toHaveBeenCalledTimes(1);
await new Promise<void>((resolve) => setImmediate(resolve));
process.emit("SIGUSR1");
await vi.waitFor(() => {
expect(start).toHaveBeenCalledTimes(2);
});
await startedSecond;
expect(start).toHaveBeenCalledTimes(2);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(waitForActiveTasks).toHaveBeenCalledWith(30_000);
expect(gatewayLog.warn).toHaveBeenCalledWith(DRAIN_TIMEOUT_LOG);

View File

@@ -933,6 +933,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
if (suspiciousReasons.length === 0) {
return;
}
// Tests often write minimal configs (missing meta, etc); keep output quiet unless requested.
const isVitest = deps.env.VITEST === "true";
const shouldLogInVitest = deps.env.OPENCLAW_TEST_CONFIG_WRITE_ANOMALY_LOG === "1";
if (isVitest && !shouldLogInVitest) {
return;
}
deps.logger.warn(`Config write anomaly: ${configPath} (${suspiciousReasons.join(", ")})`);
};
const auditRecordBase = {

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "./home-env.test-harness.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { createConfigIO } from "./io.js";
describe("config io write", () => {
@@ -10,6 +10,76 @@ describe("config io write", () => {
error: () => {},
};
type HomeEnvSnapshot = {
home: string | undefined;
userProfile: string | undefined;
homeDrive: string | undefined;
homePath: string | undefined;
stateDir: string | undefined;
};
const snapshotHomeEnv = (): HomeEnvSnapshot => ({
home: process.env.HOME,
userProfile: process.env.USERPROFILE,
homeDrive: process.env.HOMEDRIVE,
homePath: process.env.HOMEPATH,
stateDir: process.env.OPENCLAW_STATE_DIR,
});
const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => {
const restoreKey = (key: string, value: string | undefined) => {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreKey("HOME", snapshot.home);
restoreKey("USERPROFILE", snapshot.userProfile);
restoreKey("HOMEDRIVE", snapshot.homeDrive);
restoreKey("HOMEPATH", snapshot.homePath);
restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir);
};
let fixtureRoot = "";
let caseId = 0;
async function withTempHome(prefix: string, fn: (home: string) => Promise<void>): Promise<void> {
const safePrefix = prefix.trim().replace(/[^a-zA-Z0-9._-]+/g, "-") || "tmp";
const home = path.join(fixtureRoot, `${safePrefix}${caseId++}`);
await fs.mkdir(path.join(home, ".openclaw"), { recursive: true });
const snapshot = snapshotHomeEnv();
process.env.HOME = home;
process.env.USERPROFILE = home;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
if (process.platform === "win32") {
const match = home.match(/^([A-Za-z]:)(.*)$/);
if (match) {
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
}
try {
await fn(home);
} finally {
restoreHomeEnv(snapshot);
}
}
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-io-"));
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
it("persists caller changes onto resolved config without leaking runtime defaults", async () => {
await withTempHome("openclaw-config-io-", async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearSessionStoreCacheForTest,
loadSessionStore,
@@ -10,12 +10,23 @@ import {
} from "./sessions.js";
describe("Session Store Cache", () => {
let fixtureRoot = "";
let caseId = 0;
let testDir: string;
let storePath: string;
beforeAll(() => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "session-cache-test-"));
});
afterAll(() => {
if (fixtureRoot) {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});
beforeEach(() => {
// Create a temporary directory for test
testDir = path.join(os.tmpdir(), `session-cache-test-${Date.now()}`);
testDir = path.join(fixtureRoot, `case-${caseId++}`);
fs.mkdirSync(testDir, { recursive: true });
storePath = path.join(testDir, "sessions.json");
@@ -27,10 +38,6 @@ describe("Session Store Cache", () => {
});
afterEach(() => {
// Clean up test directory
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
clearSessionStoreCacheForTest();
delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS;
});

View File

@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { sleep } from "../utils.js";
import {
buildGroupDisplayName,
deriveSessionKey,
@@ -490,24 +489,39 @@ describe("sessions", () => {
"utf-8",
);
await Promise.all([
updateSessionStoreEntry({
storePath,
sessionKey: mainSessionKey,
update: async () => {
await sleep(10);
return { modelOverride: "anthropic/claude-opus-4-5" };
},
}),
updateSessionStoreEntry({
storePath,
sessionKey: mainSessionKey,
update: async () => {
await sleep(1);
return { thinkingLevel: "high" };
},
}),
]);
const createDeferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const firstStarted = createDeferred<void>();
const releaseFirst = createDeferred<void>();
const p1 = updateSessionStoreEntry({
storePath,
sessionKey: mainSessionKey,
update: async () => {
firstStarted.resolve();
await releaseFirst.promise;
return { modelOverride: "anthropic/claude-opus-4-5" };
},
});
const p2 = updateSessionStoreEntry({
storePath,
sessionKey: mainSessionKey,
update: async () => {
await firstStarted.promise;
return { thinkingLevel: "high" };
},
});
await firstStarted.promise;
releaseFirst.resolve();
await Promise.all([p1, p2]);
const store = loadSessionStore(storePath);
expect(store[mainSessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5");

View File

@@ -27,19 +27,6 @@ describe("session store lock (Promise chain mutex)", () => {
return { promise, resolve, reject };
}
async function waitForFile(filePath: string, maxTicks = 50): Promise<void> {
for (let tick = 0; tick < maxTicks; tick += 1) {
try {
await fs.access(filePath);
return;
} catch {
// Works under both real + fake timers (setImmediate is faked).
await new Promise<void>((resolve) => process.nextTick(resolve));
}
}
throw new Error(`timed out waiting for file: ${filePath}`);
}
async function makeTmpStore(
initial: Record<string, unknown> = {},
): Promise<{ dir: string; storePath: string }> {
@@ -76,8 +63,8 @@ describe("session store lock (Promise chain mutex)", () => {
[key]: { sessionId: "s1", updatedAt: 100, counter: 0 },
});
// Launch 10 concurrent read-modify-write cycles.
const N = 10;
// Launch a few concurrent read-modify-write cycles (enough to surface stale-read races).
const N = 4;
await Promise.all(
Array.from({ length: N }, (_, i) =>
updateSessionStore(storePath, async (store) => {
@@ -309,16 +296,17 @@ describe("session store lock (Promise chain mutex)", () => {
});
let timedOutRan = false;
const lockPath = `${storePath}.lock`;
const releaseLock = createDeferred<void>();
const lockStarted = createDeferred<void>();
const lockHolder = withSessionStoreLockForTest(
storePath,
async () => {
lockStarted.resolve();
await releaseLock.promise;
},
{ timeoutMs: 1_000 },
);
await waitForFile(lockPath);
await lockStarted.promise;
const timedOut = withSessionStoreLockForTest(
storePath,
async () => {
@@ -350,12 +338,15 @@ describe("session store lock (Promise chain mutex)", () => {
const lockPath = `${storePath}.lock`;
const allowWrite = createDeferred<void>();
const writeStarted = createDeferred<void>();
const write = updateSessionStore(storePath, async (store) => {
writeStarted.resolve();
await allowWrite.promise;
store[key] = { ...store[key], modelOverride: "v" } as unknown as SessionEntry;
});
await waitForFile(lockPath);
await writeStarted.promise;
await fs.access(lockPath);
allowWrite.resolve();
await write;

View File

@@ -190,12 +190,14 @@ describe("CronService interval/cron jobs fire on time", () => {
});
await cron.start();
for (let minute = 1; minute <= 6; minute++) {
// Perf: a few recomputation cycles are enough to catch legacy "every" drift.
for (let minute = 1; minute <= 3; minute++) {
vi.setSystemTime(new Date(nowMs + minute * 60_000));
const minuteRun = await cron.run("minute-cron", "force");
expect(minuteRun).toEqual({ ok: true, ran: true });
}
// "every" cadence is 2m; verify it stays due at the 6-minute boundary.
vi.setSystemTime(new Date(nowMs + 6 * 60_000));
const sfRun = await cron.run("legacy-every", "due");
expect(sfRun).toEqual({ ok: true, ran: true });

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CronEvent } from "./service.js";
import { CronService } from "./service.js";
@@ -12,14 +12,14 @@ const noopLogger = {
error: vi.fn(),
};
let fixtureRoot = "";
let caseId = 0;
async function makeStorePath() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-16156-"));
return {
storePath: path.join(dir, "cron", "jobs.json"),
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
const dir = path.join(fixtureRoot, `case-${caseId++}`);
const storePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
return { storePath };
}
function createFinishedBarrier() {
@@ -44,6 +44,16 @@ function createFinishedBarrier() {
}
describe("#16156: cron.list() must not silently advance past-due recurring jobs", () => {
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-16156-"));
});
afterAll(async () => {
if (fixtureRoot) {
await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined);
}
});
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z"));
@@ -119,7 +129,6 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs"
expect(updated?.state.nextRunAtMs).toBeGreaterThan(firstDueAt);
cron.stop();
await store.cleanup();
});
it("does not skip a cron job when status() is called while the job is past-due", async () => {
@@ -172,7 +181,6 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs"
expect(updated?.state.lastStatus).toBe("ok");
cron.stop();
await store.cleanup();
});
it("still fills missing nextRunAtMs via list() for enabled jobs", async () => {
@@ -226,6 +234,5 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs"
expect(job?.state.nextRunAtMs).toBeGreaterThan(nowMs);
cron.stop();
await store.cleanup();
});
});

View File

@@ -24,9 +24,6 @@ async function makeStorePath() {
const storePath = path.join(dir, "jobs.json");
return {
storePath,
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
@@ -152,7 +149,6 @@ describe("Cron issue regressions", () => {
}
cron.stop();
await store.cleanup();
});
it("repairs missing nextRunAtMs on non-schedule updates without touching other jobs", async () => {
@@ -183,7 +179,6 @@ describe("Cron issue regressions", () => {
expect(updated.state.nextRunAtMs).toBe(created.state.nextRunAtMs);
cron.stop();
await store.cleanup();
});
it("does not advance unrelated due jobs when updating another job", async () => {
@@ -230,7 +225,6 @@ describe("Cron issue regressions", () => {
expect(persistedDueJob?.state?.nextRunAtMs).toBe(originalDueNextRunAtMs);
cron.stop();
await store.cleanup();
});
it("treats persisted jobs with missing enabled as enabled during update()", async () => {
@@ -366,7 +360,6 @@ describe("Cron issue regressions", () => {
cron.stop();
timeoutSpy.mockRestore();
await store.cleanup();
});
it("re-arms timer without hot-looping when a run is already in progress", async () => {
@@ -400,7 +393,6 @@ describe("Cron issue regressions", () => {
.filter((d): d is number => typeof d === "number");
expect(delays).toContain(60_000);
timeoutSpy.mockRestore();
await store.cleanup();
});
it("skips forced manual runs while a timer-triggered run is in progress", async () => {
@@ -467,7 +459,6 @@ describe("Cron issue regressions", () => {
await cron.list({ includeDisabled: true });
cron.stop();
await store.cleanup();
});
it("#13845: one-shot jobs with terminal statuses do not re-fire on restart", async () => {
@@ -523,7 +514,6 @@ describe("Cron issue regressions", () => {
expect(enqueueSystemEvent).not.toHaveBeenCalled();
cron.stop();
}
await store.cleanup();
});
it("records per-job start time and duration for batched due jobs", async () => {
@@ -569,7 +559,5 @@ describe("Cron issue regressions", () => {
expect(secondDone?.state.lastRunAtMs).toBe(dueAt + 50);
expect(secondDone?.state.lastDurationMs).toBe(20);
expect(startedAtEvents).toEqual([dueAt, dueAt + 50]);
await store.cleanup();
});
});

View File

@@ -1,19 +1,201 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js";
import type { CronEvent } from "./service.js";
import { CronService } from "./service.js";
import {
createCronStoreHarness,
createNoopLogger,
installCronTestHooks,
} from "./service.test-harness.js";
import { createNoopLogger, installCronTestHooks } from "./service.test-harness.js";
const noopLogger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness();
installCronTestHooks({ logger: noopLogger });
type FakeFsEntry =
| { kind: "file"; content: string; mtimeMs: number }
| { kind: "dir"; mtimeMs: number };
const fsState = vi.hoisted(() => ({
entries: new Map<string, FakeFsEntry>(),
nowMs: 0,
fixtureCount: 0,
}));
const abs = (p: string) => path.resolve(p);
const fixturesRoot = abs(path.join("__openclaw_vitest__", "cron", "runs-one-shot"));
const isFixturePath = (p: string) => {
const resolved = abs(p);
const rootPrefix = `${fixturesRoot}${path.sep}`;
return resolved === fixturesRoot || resolved.startsWith(rootPrefix);
};
function bumpMtimeMs() {
fsState.nowMs += 1;
return fsState.nowMs;
}
function ensureDir(dirPath: string) {
let current = abs(dirPath);
while (true) {
if (!fsState.entries.has(current)) {
fsState.entries.set(current, { kind: "dir", mtimeMs: bumpMtimeMs() });
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
}
function setFile(filePath: string, content: string) {
const resolved = abs(filePath);
ensureDir(path.dirname(resolved));
fsState.entries.set(resolved, { kind: "file", content, mtimeMs: bumpMtimeMs() });
}
async function makeStorePath() {
const dir = path.join(fixturesRoot, `case-${fsState.fixtureCount++}`);
ensureDir(dir);
const storePath = path.join(dir, "cron", "jobs.json");
ensureDir(path.dirname(storePath));
return { storePath, cleanup: async () => {} };
}
function writeStoreFile(storePath: string, payload: unknown) {
setFile(storePath, JSON.stringify(payload, null, 2));
}
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const pathMod = await import("node:path");
const absInMock = (p: string) => pathMod.resolve(p);
const isFixtureInMock = (p: string) => {
const resolved = absInMock(p);
const rootPrefix = `${absInMock(fixturesRoot)}${pathMod.sep}`;
return resolved === absInMock(fixturesRoot) || resolved.startsWith(rootPrefix);
};
const mkErr = (code: string, message: string) => Object.assign(new Error(message), { code });
const promises = {
...actual.promises,
mkdir: async (p: string) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.mkdir as any)(p, { recursive: true });
}
ensureDir(p);
},
readFile: async (p: string) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.readFile as any)(p, "utf-8");
}
const entry = fsState.entries.get(absInMock(p));
if (!entry || entry.kind !== "file") {
throw mkErr("ENOENT", `ENOENT: no such file or directory, open '${p}'`);
}
return entry.content;
},
writeFile: async (p: string, data: string | Uint8Array) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.writeFile as any)(p, data, "utf-8");
}
const content = typeof data === "string" ? data : Buffer.from(data).toString("utf-8");
setFile(p, content);
},
rename: async (from: string, to: string) => {
if (!isFixtureInMock(from) || !isFixtureInMock(to)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.rename as any)(from, to);
}
const fromAbs = absInMock(from);
const toAbs = absInMock(to);
const entry = fsState.entries.get(fromAbs);
if (!entry || entry.kind !== "file") {
throw mkErr("ENOENT", `ENOENT: no such file or directory, rename '${from}' -> '${to}'`);
}
ensureDir(pathMod.dirname(toAbs));
fsState.entries.delete(fromAbs);
fsState.entries.set(toAbs, { ...entry, mtimeMs: bumpMtimeMs() });
},
copyFile: async (from: string, to: string) => {
if (!isFixtureInMock(from) || !isFixtureInMock(to)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.copyFile as any)(from, to);
}
const entry = fsState.entries.get(absInMock(from));
if (!entry || entry.kind !== "file") {
throw mkErr("ENOENT", `ENOENT: no such file or directory, copyfile '${from}' -> '${to}'`);
}
setFile(to, entry.content);
},
stat: async (p: string) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.stat as any)(p);
}
const entry = fsState.entries.get(absInMock(p));
if (!entry) {
throw mkErr("ENOENT", `ENOENT: no such file or directory, stat '${p}'`);
}
return {
mtimeMs: entry.mtimeMs,
isDirectory: () => entry.kind === "dir",
isFile: () => entry.kind === "file",
};
},
access: async (p: string) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.access as any)(p);
}
const entry = fsState.entries.get(absInMock(p));
if (!entry) {
throw mkErr("ENOENT", `ENOENT: no such file or directory, access '${p}'`);
}
},
unlink: async (p: string) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.unlink as any)(p);
}
fsState.entries.delete(absInMock(p));
},
} satisfies typeof actual.promises;
const wrapped = { ...actual, promises };
return { ...wrapped, default: wrapped };
});
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
const wrapped = {
...actual,
mkdir: async (p: string, _opts?: unknown) => {
if (!isFixturePath(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.mkdir as any)(p, { recursive: true });
}
ensureDir(p);
},
writeFile: async (p: string, data: string, _enc?: unknown) => {
if (!isFixturePath(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.writeFile as any)(p, data, "utf-8");
}
setFile(p, data);
},
};
return { ...wrapped, default: wrapped };
});
beforeEach(() => {
fsState.entries.clear();
fsState.nowMs = 0;
fsState.fixtureCount = 0;
ensureDir(fixturesRoot);
});
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -57,35 +239,8 @@ function createCronEventHarness() {
}
describe("CronService", () => {
async function loadLegacyJobFromStore(rawJob: Record<string, unknown>) {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify({ version: 1, jobs: [rawJob] }, null, 2),
"utf-8",
);
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
});
await cron.start();
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id);
return { cron, store, enqueueSystemEvent, requestHeartbeatNow, job };
}
it("runs a one-shot main job and disables it after success when requested", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -134,6 +289,7 @@ describe("CronService", () => {
});
it("runs a one-shot job and deletes it after success by default", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -177,6 +333,7 @@ describe("CronService", () => {
});
it("wakeMode now waits for heartbeat completion when available", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -244,6 +401,7 @@ describe("CronService", () => {
});
it("passes agentId to runHeartbeatOnce for main-session wakeMode now jobs", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -293,6 +451,7 @@ describe("CronService", () => {
});
it("wakeMode now falls back to queued heartbeat when main lane stays busy", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -343,6 +502,7 @@ describe("CronService", () => {
});
it("runs an isolated job and posts summary to main", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -391,6 +551,7 @@ describe("CronService", () => {
});
it("does not post isolated summary to main when run already delivered output", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -437,6 +598,11 @@ describe("CronService", () => {
});
it("migrates legacy payload.provider to payload.channel on load", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const rawJob = {
id: "legacy-1",
name: "legacy",
@@ -456,7 +622,20 @@ describe("CronService", () => {
state: {},
};
const { cron, store, job } = await loadLegacyJobFromStore(rawJob);
writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] });
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
});
await cron.start();
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id);
// Legacy delivery fields are migrated to the top-level delivery object
const delivery = job?.delivery as unknown as Record<string, unknown>;
expect(delivery?.channel).toBe("telegram");
@@ -469,6 +648,11 @@ describe("CronService", () => {
});
it("canonicalizes payload.channel casing on load", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const rawJob = {
id: "legacy-2",
name: "legacy",
@@ -488,7 +672,20 @@ describe("CronService", () => {
state: {},
};
const { cron, store, job } = await loadLegacyJobFromStore(rawJob);
writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] });
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
});
await cron.start();
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id);
// Legacy delivery fields are migrated to the top-level delivery object
const delivery = job?.delivery as unknown as Record<string, unknown>;
expect(delivery?.channel).toBe("telegram");
@@ -498,6 +695,7 @@ describe("CronService", () => {
});
it("posts last output to main even when isolated job errors", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -546,6 +744,7 @@ describe("CronService", () => {
});
it("rejects unsupported session/payload combinations", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const cron = new CronService({
@@ -586,32 +785,29 @@ describe("CronService", () => {
});
it("skips invalid main jobs with agentTurn payloads from disk", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const events = createCronEventHarness();
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify({
version: 1,
jobs: [
{
id: "job-1",
enabled: true,
createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "agentTurn", message: "bad" },
state: {},
},
],
}),
);
writeStoreFile(store.storePath, {
version: 1,
jobs: [
{
id: "job-1",
enabled: true,
createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "agentTurn", message: "bad" },
state: {},
},
],
});
const cron = new CronService({
storePath: store.storePath,

View File

@@ -1,8 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
installLaunchAgent,
isLaunchAgentListed,
@@ -11,116 +8,69 @@ import {
resolveLaunchAgentPlistPath,
} from "./launchd.js";
function parseLaunchctlCalls(raw: string): string[][] {
return raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.split(/\s+/));
}
const state = vi.hoisted(() => ({
launchctlCalls: [] as string[][],
listOutput: "",
dirs: new Set<string>(),
files: new Map<string, string>(),
}));
async function writeLaunchctlStub(binDir: string) {
if (process.platform === "win32") {
const stubJsPath = path.join(binDir, "launchctl.js");
await fs.writeFile(
stubJsPath,
[
'import fs from "node:fs";',
"const args = process.argv.slice(2);",
"const logPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG;",
"if (logPath) {",
' fs.appendFileSync(logPath, args.join("\\t") + "\\n", "utf8");',
"}",
'if (args[0] === "list") {',
' const output = process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT || "";',
" process.stdout.write(output);",
"}",
"process.exit(0);",
"",
].join("\n"),
"utf8",
);
await fs.writeFile(
path.join(binDir, "launchctl.cmd"),
`@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`,
"utf8",
);
return;
function normalizeLaunchctlArgs(file: string, args: string[]): string[] {
if (file === "launchctl") {
return args;
}
const shPath = path.join(binDir, "launchctl");
await fs.writeFile(
shPath,
[
"#!/bin/sh",
'log_path="${OPENCLAW_TEST_LAUNCHCTL_LOG:-}"',
'if [ -n "$log_path" ]; then',
' line=""',
' for arg in "$@"; do',
' if [ -n "$line" ]; then',
' line="$line $arg"',
" else",
' line="$arg"',
" fi",
" done",
' printf \'%s\\n\' "$line" >> "$log_path"',
"fi",
'if [ "$1" = "list" ]; then',
" printf '%s' \"${OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT:-}\"",
"fi",
"exit 0",
"",
].join("\n"),
"utf8",
);
await fs.chmod(shPath, 0o755);
}
async function withLaunchctlStub(
options: { listOutput?: string },
run: (context: { env: Record<string, string | undefined>; logPath: string }) => Promise<void>,
) {
const originalPath = process.env.PATH;
const originalLogPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG;
const originalListOutput = process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-launchctl-test-"));
try {
const binDir = path.join(tmpDir, "bin");
const homeDir = path.join(tmpDir, "home");
const logPath = path.join(tmpDir, "launchctl.log");
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(homeDir, { recursive: true });
await writeLaunchctlStub(binDir);
process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = logPath;
process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT = options.listOutput ?? "";
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`;
await run({
env: {
HOME: homeDir,
OPENCLAW_PROFILE: "default",
},
logPath,
});
} finally {
process.env.PATH = originalPath;
if (originalLogPath === undefined) {
delete process.env.OPENCLAW_TEST_LAUNCHCTL_LOG;
} else {
process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = originalLogPath;
}
if (originalListOutput === undefined) {
delete process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT;
} else {
process.env.OPENCLAW_TEST_LAUNCHCTL_LIST_OUTPUT = originalListOutput;
}
await fs.rm(tmpDir, { recursive: true, force: true });
const idx = args.indexOf("launchctl");
if (idx >= 0) {
return args.slice(idx + 1);
}
return args;
}
vi.mock("./exec-file.js", () => ({
execFileUtf8: vi.fn(async (file: string, args: string[]) => {
const call = normalizeLaunchctlArgs(file, args);
state.launchctlCalls.push(call);
if (call[0] === "list") {
return { stdout: state.listOutput, stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
}),
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
const wrapped = {
...actual,
access: vi.fn(async (p: string) => {
const key = String(p);
if (state.files.has(key) || state.dirs.has(key)) {
return;
}
throw new Error(`ENOENT: no such file or directory, access '${key}'`);
}),
mkdir: vi.fn(async (p: string) => {
state.dirs.add(String(p));
}),
unlink: vi.fn(async (p: string) => {
state.files.delete(String(p));
}),
writeFile: vi.fn(async (p: string, data: string) => {
const key = String(p);
state.files.set(key, data);
state.dirs.add(String(key.split("/").slice(0, -1).join("/")));
}),
};
return { ...wrapped, default: wrapped };
});
beforeEach(() => {
state.launchctlCalls.length = 0;
state.listOutput = "";
state.dirs.clear();
state.files.clear();
vi.clearAllMocks();
});
describe("launchd runtime parsing", () => {
it("parses state, pid, and exit status", () => {
const output = [
@@ -140,92 +90,66 @@ describe("launchd runtime parsing", () => {
describe("launchctl list detection", () => {
it("detects the resolved label in launchctl list", async () => {
await withLaunchctlStub({ listOutput: "123 0 ai.openclaw.gateway\n" }, async ({ env }) => {
const listed = await isLaunchAgentListed({ env });
expect(listed).toBe(true);
state.listOutput = "123 0 ai.openclaw.gateway\n";
const listed = await isLaunchAgentListed({
env: { HOME: "/Users/test", OPENCLAW_PROFILE: "default" },
});
expect(listed).toBe(true);
});
it("returns false when the label is missing", async () => {
await withLaunchctlStub({ listOutput: "123 0 com.other.service\n" }, async ({ env }) => {
const listed = await isLaunchAgentListed({ env });
expect(listed).toBe(false);
state.listOutput = "123 0 com.other.service\n";
const listed = await isLaunchAgentListed({
env: { HOME: "/Users/test", OPENCLAW_PROFILE: "default" },
});
expect(listed).toBe(false);
});
});
describe("launchd bootstrap repair", () => {
it("bootstraps and kickstarts the resolved label", async () => {
await withLaunchctlStub({}, async ({ env, logPath }) => {
const repair = await repairLaunchAgentBootstrap({ env });
expect(repair.ok).toBe(true);
const env: Record<string, string | undefined> = {
HOME: "/Users/test",
OPENCLAW_PROFILE: "default",
};
const repair = await repairLaunchAgentBootstrap({ env });
expect(repair.ok).toBe(true);
const calls = parseLaunchctlCalls(await fs.readFile(logPath, "utf8"));
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "ai.openclaw.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "ai.openclaw.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
expect(calls).toContainEqual(["bootstrap", domain, plistPath]);
expect(calls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]);
});
expect(state.launchctlCalls).toContainEqual(["bootstrap", domain, plistPath]);
expect(state.launchctlCalls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]);
});
});
describe("launchd install", () => {
it("enables service before bootstrap (clears persisted disabled state)", async () => {
const originalPath = process.env.PATH;
const originalLogPath = process.env.OPENCLAW_TEST_LAUNCHCTL_LOG;
const env: Record<string, string | undefined> = {
HOME: "/Users/test",
OPENCLAW_PROFILE: "default",
};
await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: ["node", "-e", "process.exit(0)"],
});
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-launchctl-test-"));
try {
const binDir = path.join(tmpDir, "bin");
const homeDir = path.join(tmpDir, "home");
const logPath = path.join(tmpDir, "launchctl.log");
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(homeDir, { recursive: true });
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "ai.openclaw.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
const serviceId = `${domain}/${label}`;
await writeLaunchctlStub(binDir);
process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = logPath;
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`;
const env: Record<string, string | undefined> = {
HOME: homeDir,
OPENCLAW_PROFILE: "default",
};
await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: ["node", "-e", "process.exit(0)"],
});
const calls = parseLaunchctlCalls(await fs.readFile(logPath, "utf8"));
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "ai.openclaw.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
const serviceId = `${domain}/${label}`;
const enableCalls = calls.filter((c) => c[0] === "enable" && c[1] === serviceId);
expect(enableCalls).toHaveLength(1);
const enableIndex = calls.findIndex((c) => c[0] === "enable" && c[1] === serviceId);
const bootstrapIndex = calls.findIndex(
(c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath,
);
expect(enableIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeGreaterThanOrEqual(0);
expect(enableIndex).toBeLessThan(bootstrapIndex);
} finally {
process.env.PATH = originalPath;
if (originalLogPath === undefined) {
delete process.env.OPENCLAW_TEST_LAUNCHCTL_LOG;
} else {
process.env.OPENCLAW_TEST_LAUNCHCTL_LOG = originalLogPath;
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
const enableIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "enable" && c[1] === serviceId,
);
const bootstrapIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath,
);
expect(enableIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeGreaterThanOrEqual(0);
expect(enableIndex).toBeLessThan(bootstrapIndex);
});
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import {
archiveSessionTranscripts,
readFirstUserMessageFromTranscript,
@@ -16,12 +16,12 @@ describe("readFirstUserMessageFromTranscript", () => {
let tmpDir: string;
let storePath: string;
beforeEach(() => {
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-"));
storePath = path.join(tmpDir, "sessions.json");
});
afterEach(() => {
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
@@ -183,12 +183,12 @@ describe("readLastMessagePreviewFromTranscript", () => {
let tmpDir: string;
let storePath: string;
beforeEach(() => {
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-"));
storePath = path.join(tmpDir, "sessions.json");
});
afterEach(() => {
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
@@ -345,7 +345,7 @@ describe("readLastMessagePreviewFromTranscript", () => {
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } });
const lines: string[] = [];
for (let i = 0; i < 50; i++) {
for (let i = 0; i < 30; i++) {
lines.push(padding);
}
lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } }));
@@ -372,12 +372,12 @@ describe("readSessionTitleFieldsFromTranscript cache", () => {
let tmpDir: string;
let storePath: string;
beforeEach(() => {
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-"));
storePath = path.join(tmpDir, "sessions.json");
});
afterEach(() => {
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
@@ -400,6 +400,7 @@ describe("readSessionTitleFieldsFromTranscript cache", () => {
const second = readSessionTitleFieldsFromTranscript(sessionId, storePath);
expect(second).toEqual(first);
expect(readSpy.mock.calls.length).toBe(readsAfterFirst);
readSpy.mockRestore();
});
test("invalidates cache when transcript changes", () => {
@@ -427,6 +428,7 @@ describe("readSessionTitleFieldsFromTranscript cache", () => {
const second = readSessionTitleFieldsFromTranscript(sessionId, storePath);
expect(second.lastMessagePreview).toBe("New");
expect(readSpy.mock.calls.length).toBeGreaterThan(readsAfterFirst);
readSpy.mockRestore();
});
});
@@ -434,12 +436,12 @@ describe("readSessionMessages", () => {
let tmpDir: string;
let storePath: string;
beforeEach(() => {
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-"));
storePath = path.join(tmpDir, "sessions.json");
});
afterEach(() => {
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
@@ -533,12 +535,12 @@ describe("readSessionPreviewItemsFromTranscript", () => {
let tmpDir: string;
let storePath: string;
beforeEach(() => {
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-preview-test-"));
storePath = path.join(tmpDir, "sessions.json");
});
afterEach(() => {
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
@@ -690,13 +692,13 @@ describe("archiveSessionTranscripts", () => {
let tmpDir: string;
let storePath: string;
beforeEach(() => {
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-archive-test-"));
storePath = path.join(tmpDir, "sessions.json");
vi.stubEnv("OPENCLAW_HOME", tmpDir);
});
afterEach(() => {
afterAll(() => {
vi.unstubAllEnvs();
fs.rmSync(tmpDir, { recursive: true, force: true });
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
clearInternalHooks,
@@ -12,13 +12,19 @@ import {
import { loadInternalHooks } from "./loader.js";
describe("loader", () => {
let fixtureRoot = "";
let caseId = 0;
let tmpDir: string;
let originalBundledDir: string | undefined;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hooks-loader-"));
});
beforeEach(async () => {
clearInternalHooks();
// Create a temp directory for test modules
tmpDir = path.join(os.tmpdir(), `openclaw-test-${Date.now()}`);
tmpDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(tmpDir, { recursive: true });
// Disable bundled hooks during tests by setting env var to non-existent directory
@@ -34,12 +40,13 @@ describe("loader", () => {
} else {
process.env.OPENCLAW_BUNDLED_HOOKS_DIR = originalBundledDir;
}
// Clean up temp directory
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
describe("loadInternalHooks", () => {

View File

@@ -1,299 +1,203 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
resolveControlUiDistIndexHealth,
resolveControlUiDistIndexPath,
resolveControlUiDistIndexPathForRoot,
resolveControlUiRepoRoot,
resolveControlUiRootOverrideSync,
resolveControlUiRootSync,
} from "./control-ui-assets.js";
import { resolveOpenClawPackageRoot } from "./openclaw-root.js";
import { pathToFileURL } from "node:url";
import { beforeEach, describe, expect, it, vi } from "vitest";
/** Try to create a symlink; returns false if the OS denies it (Windows CI without Developer Mode). */
async function trySymlink(target: string, linkPath: string): Promise<boolean> {
try {
await fs.symlink(target, linkPath);
return true;
} catch {
return false;
}
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
const state = vi.hoisted(() => ({
entries: new Map<string, FakeFsEntry>(),
realpaths: new Map<string, string>(),
}));
const abs = (p: string) => path.resolve(p);
function setFile(p: string, content = "") {
state.entries.set(abs(p), { kind: "file", content });
}
async function canonicalPath(p: string): Promise<string> {
try {
return await fs.realpath(p);
} catch {
return path.resolve(p);
}
function setDir(p: string) {
state.entries.set(abs(p), { kind: "dir" });
}
describe("control UI assets helpers", () => {
let fixtureRoot = "";
let caseId = 0;
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const pathMod = await import("node:path");
const absInMock = (p: string) => pathMod.resolve(p);
const fixturesRoot = `${absInMock("fixtures")}${pathMod.sep}`;
const isFixturePath = (p: string) => {
const resolved = absInMock(p);
return resolved === fixturesRoot.slice(0, -1) || resolved.startsWith(fixturesRoot);
};
async function withTempDir<T>(fn: (tmp: string) => Promise<T>): Promise<T> {
const tmp = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(tmp, { recursive: true });
return await fn(tmp);
}
const wrapped = {
...actual,
existsSync: (p: string) =>
isFixturePath(p) ? state.entries.has(absInMock(p)) : actual.existsSync(p),
readFileSync: (p: string, encoding?: unknown) => {
if (!isFixturePath(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return actual.readFileSync(p as any, encoding as any) as unknown;
}
const entry = state.entries.get(absInMock(p));
if (!entry || entry.kind !== "file") {
throw new Error(`ENOENT: no such file, open '${p}'`);
}
return entry.content;
},
statSync: (p: string) => {
if (!isFixturePath(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return actual.statSync(p as any) as unknown;
}
const entry = state.entries.get(absInMock(p));
if (!entry) {
throw new Error(`ENOENT: no such file or directory, stat '${p}'`);
}
return {
isFile: () => entry.kind === "file",
isDirectory: () => entry.kind === "dir",
};
},
realpathSync: (p: string) =>
isFixturePath(p)
? (state.realpaths.get(absInMock(p)) ?? absInMock(p))
: actual.realpathSync(p),
};
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
});
return { ...wrapped, default: wrapped };
});
afterAll(async () => {
if (fixtureRoot) {
await fs.rm(fixtureRoot, { recursive: true, force: true });
}
vi.mock("./openclaw-root.js", () => ({
resolveOpenClawPackageRoot: vi.fn(async () => null),
resolveOpenClawPackageRootSync: vi.fn(() => null),
}));
describe("control UI assets helpers (fs-mocked)", () => {
beforeEach(() => {
state.entries.clear();
state.realpaths.clear();
vi.clearAllMocks();
});
it("resolves repo root from src argv1", async () => {
await withTempDir(async (tmp) => {
await fs.mkdir(path.join(tmp, "ui"), { recursive: true });
await fs.writeFile(path.join(tmp, "ui", "vite.config.ts"), "export {};\n");
await fs.writeFile(path.join(tmp, "package.json"), "{}\n");
await fs.mkdir(path.join(tmp, "src"), { recursive: true });
await fs.writeFile(path.join(tmp, "src", "index.ts"), "export {};\n");
const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js");
expect(resolveControlUiRepoRoot(path.join(tmp, "src", "index.ts"))).toBe(tmp);
});
const root = abs("fixtures/ui-src");
setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n");
const argv1 = path.join(root, "src", "index.ts");
expect(resolveControlUiRepoRoot(argv1)).toBe(root);
});
it("resolves repo root from dist argv1", async () => {
await withTempDir(async (tmp) => {
await fs.mkdir(path.join(tmp, "ui"), { recursive: true });
await fs.writeFile(path.join(tmp, "ui", "vite.config.ts"), "export {};\n");
await fs.writeFile(path.join(tmp, "package.json"), "{}\n");
await fs.mkdir(path.join(tmp, "dist"), { recursive: true });
await fs.writeFile(path.join(tmp, "dist", "index.js"), "export {};\n");
it("resolves repo root by traversing up (dist argv1)", async () => {
const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js");
expect(resolveControlUiRepoRoot(path.join(tmp, "dist", "index.js"))).toBe(tmp);
});
const root = abs("fixtures/ui-dist");
setFile(path.join(root, "package.json"), "{}\n");
setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n");
const argv1 = path.join(root, "dist", "index.js");
expect(resolveControlUiRepoRoot(argv1)).toBe(root);
});
it("resolves dist control-ui index path for dist argv1", async () => {
const argv1 = path.resolve("/tmp", "pkg", "dist", "index.js");
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
const argv1 = abs(path.join("fixtures", "pkg", "dist", "index.js"));
const distDir = path.dirname(argv1);
expect(await resolveControlUiDistIndexPath(argv1)).toBe(
await expect(resolveControlUiDistIndexPath(argv1)).resolves.toBe(
path.join(distDir, "control-ui", "index.html"),
);
});
it("resolves control-ui root for dist bundle argv1", async () => {
await withTempDir(async (tmp) => {
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(tmp, "dist", "bundle.js"), "export {};\n");
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
it("uses resolveOpenClawPackageRoot when available", async () => {
const openclawRoot = await import("./openclaw-root.js");
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
expect(resolveControlUiRootSync({ argv1: path.join(tmp, "dist", "bundle.js") })).toBe(
path.join(tmp, "dist", "control-ui"),
);
const pkgRoot = abs("fixtures/openclaw");
(
openclawRoot.resolveOpenClawPackageRoot as unknown as ReturnType<typeof vi.fn>
).mockResolvedValueOnce(pkgRoot);
await expect(resolveControlUiDistIndexPath(abs("fixtures/bin/openclaw"))).resolves.toBe(
path.join(pkgRoot, "dist", "control-ui", "index.html"),
);
});
it("falls back to package.json name matching when root resolution fails", async () => {
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
const root = abs("fixtures/fallback");
setFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
setFile(path.join(root, "dist", "control-ui", "index.html"), "<html></html>\n");
await expect(resolveControlUiDistIndexPath(path.join(root, "openclaw.mjs"))).resolves.toBe(
path.join(root, "dist", "control-ui", "index.html"),
);
});
it("returns null when fallback package name does not match", async () => {
const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js");
const root = abs("fixtures/not-openclaw");
setFile(path.join(root, "package.json"), JSON.stringify({ name: "malicious-pkg" }));
setFile(path.join(root, "dist", "control-ui", "index.html"), "<html></html>\n");
await expect(resolveControlUiDistIndexPath(path.join(root, "index.mjs"))).resolves.toBeNull();
});
it("reports health for missing + existing dist assets", async () => {
const { resolveControlUiDistIndexHealth } = await import("./control-ui-assets.js");
const root = abs("fixtures/health");
const indexPath = path.join(root, "dist", "control-ui", "index.html");
await expect(resolveControlUiDistIndexHealth({ root })).resolves.toEqual({
indexPath,
exists: false,
});
setFile(indexPath, "<html></html>\n");
await expect(resolveControlUiDistIndexHealth({ root })).resolves.toEqual({
indexPath,
exists: true,
});
});
it("resolves control-ui root for dist/gateway bundle argv1", async () => {
await withTempDir(async (tmp) => {
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.mkdir(path.join(tmp, "dist", "gateway"), { recursive: true });
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(tmp, "dist", "gateway", "control-ui.js"), "export {};\n");
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
it("resolves control-ui root from override file or directory", async () => {
const { resolveControlUiRootOverrideSync } = await import("./control-ui-assets.js");
expect(
resolveControlUiRootSync({ argv1: path.join(tmp, "dist", "gateway", "control-ui.js") }),
).toBe(path.join(tmp, "dist", "control-ui"));
});
const root = abs("fixtures/override");
const uiDir = path.join(root, "dist", "control-ui");
const indexPath = path.join(uiDir, "index.html");
setDir(uiDir);
setFile(indexPath, "<html></html>\n");
expect(resolveControlUiRootOverrideSync(uiDir)).toBe(uiDir);
expect(resolveControlUiRootOverrideSync(indexPath)).toBe(uiDir);
expect(resolveControlUiRootOverrideSync(path.join(uiDir, "missing.html"))).toBeNull();
});
it("resolves control-ui root from override directory or index.html", async () => {
await withTempDir(async (tmp) => {
const uiDir = path.join(tmp, "dist", "control-ui");
await fs.mkdir(uiDir, { recursive: true });
await fs.writeFile(path.join(uiDir, "index.html"), "<html></html>\n");
it("resolves control-ui root for dist bundle argv1 and moduleUrl candidates", async () => {
const openclawRoot = await import("./openclaw-root.js");
const { resolveControlUiRootSync } = await import("./control-ui-assets.js");
expect(resolveControlUiRootOverrideSync(uiDir)).toBe(uiDir);
expect(resolveControlUiRootOverrideSync(path.join(uiDir, "index.html"))).toBe(uiDir);
expect(resolveControlUiRootOverrideSync(path.join(uiDir, "missing.html"))).toBeNull();
});
});
const pkgRoot = abs("fixtures/openclaw-bundle");
(
openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType<typeof vi.fn>
).mockReturnValueOnce(pkgRoot);
it("resolves dist control-ui index path from package root argv1", async () => {
await withTempDir(async (tmp) => {
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n");
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
const uiDir = path.join(pkgRoot, "dist", "control-ui");
setFile(path.join(uiDir, "index.html"), "<html></html>\n");
expect(await resolveControlUiDistIndexPath(path.join(tmp, "openclaw.mjs"))).toBe(
path.join(tmp, "dist", "control-ui", "index.html"),
);
});
});
// argv1Dir candidate: <argv1Dir>/control-ui
expect(resolveControlUiRootSync({ argv1: path.join(pkgRoot, "dist", "bundle.js") })).toBe(
uiDir,
);
it("resolves control-ui root for package entrypoint argv1", async () => {
await withTempDir(async (tmp) => {
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n");
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
expect(resolveControlUiRootSync({ argv1: path.join(tmp, "openclaw.mjs") })).toBe(
path.join(tmp, "dist", "control-ui"),
);
});
});
it("resolves dist control-ui index path from .bin argv1", async () => {
await withTempDir(async (tmp) => {
const binDir = path.join(tmp, "node_modules", ".bin");
const pkgRoot = path.join(tmp, "node_modules", "openclaw");
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(path.join(pkgRoot, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(binDir, "openclaw"), "#!/usr/bin/env node\n");
await fs.writeFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.writeFile(path.join(pkgRoot, "dist", "control-ui", "index.html"), "<html></html>\n");
expect(await resolveControlUiDistIndexPath(path.join(binDir, "openclaw"))).toBe(
path.join(pkgRoot, "dist", "control-ui", "index.html"),
);
});
});
it("resolves via fallback when package root resolution fails but package name matches", async () => {
await withTempDir(async (tmp) => {
// Package named "openclaw" but resolveOpenClawPackageRoot failed for other reasons
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n");
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
expect(await resolveControlUiDistIndexPath(path.join(tmp, "openclaw.mjs"))).toBe(
path.join(tmp, "dist", "control-ui", "index.html"),
);
});
});
it("returns null when package name does not match openclaw", async () => {
await withTempDir(async (tmp) => {
// Package with different name should not be resolved
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "malicious-pkg" }));
await fs.writeFile(path.join(tmp, "index.mjs"), "export {};\n");
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
expect(await resolveControlUiDistIndexPath(path.join(tmp, "index.mjs"))).toBeNull();
});
});
it("returns null when no control-ui assets exist", async () => {
await withTempDir(async (tmp) => {
// Just a package.json, no dist/control-ui
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "some-pkg" }));
await fs.writeFile(path.join(tmp, "index.mjs"), "export {};\n");
expect(await resolveControlUiDistIndexPath(path.join(tmp, "index.mjs"))).toBeNull();
});
});
it("reports health for existing control-ui assets at a known root", async () => {
await withTempDir(async (tmp) => {
const indexPath = resolveControlUiDistIndexPathForRoot(tmp);
await fs.mkdir(path.dirname(indexPath), { recursive: true });
await fs.writeFile(indexPath, "<html></html>\n");
await expect(resolveControlUiDistIndexHealth({ root: tmp })).resolves.toEqual({
indexPath,
exists: true,
});
});
});
it("reports health for missing control-ui assets at a known root", async () => {
await withTempDir(async (tmp) => {
const indexPath = resolveControlUiDistIndexPathForRoot(tmp);
await expect(resolveControlUiDistIndexHealth({ root: tmp })).resolves.toEqual({
indexPath,
exists: false,
});
});
});
it("resolves control-ui root when argv1 is a symlink (nvm scenario)", async () => {
await withTempDir(async (tmp) => {
const realPkg = path.join(tmp, "real-pkg");
const bin = path.join(tmp, "bin");
await fs.mkdir(realPkg, { recursive: true });
await fs.mkdir(bin, { recursive: true });
await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n");
await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "<html></html>\n");
const ok = await trySymlink(
path.join("..", "real-pkg", "openclaw.mjs"),
path.join(bin, "openclaw"),
);
if (!ok) {
return; // symlinks not supported (Windows CI)
}
const resolvedRoot = resolveControlUiRootSync({ argv1: path.join(bin, "openclaw") });
expect(resolvedRoot).not.toBeNull();
expect(await canonicalPath(resolvedRoot ?? "")).toBe(
await canonicalPath(path.join(realPkg, "dist", "control-ui")),
);
});
});
it("resolves package root via symlinked argv1", async () => {
await withTempDir(async (tmp) => {
const realPkg = path.join(tmp, "real-pkg");
const bin = path.join(tmp, "bin");
await fs.mkdir(realPkg, { recursive: true });
await fs.mkdir(bin, { recursive: true });
await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n");
await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "<html></html>\n");
const ok = await trySymlink(
path.join("..", "real-pkg", "openclaw.mjs"),
path.join(bin, "openclaw"),
);
if (!ok) {
return; // symlinks not supported (Windows CI)
}
const packageRoot = await resolveOpenClawPackageRoot({ argv1: path.join(bin, "openclaw") });
expect(packageRoot).not.toBeNull();
expect(await canonicalPath(packageRoot ?? "")).toBe(await canonicalPath(realPkg));
});
});
it("resolves dist index path via symlinked argv1 (async)", async () => {
await withTempDir(async (tmp) => {
const realPkg = path.join(tmp, "real-pkg");
const bin = path.join(tmp, "bin");
await fs.mkdir(realPkg, { recursive: true });
await fs.mkdir(bin, { recursive: true });
await fs.writeFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.writeFile(path.join(realPkg, "openclaw.mjs"), "export {};\n");
await fs.mkdir(path.join(realPkg, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(realPkg, "dist", "control-ui", "index.html"), "<html></html>\n");
const ok = await trySymlink(
path.join("..", "real-pkg", "openclaw.mjs"),
path.join(bin, "openclaw"),
);
if (!ok) {
return; // symlinks not supported (Windows CI)
}
const indexPath = await resolveControlUiDistIndexPath(path.join(bin, "openclaw"));
expect(indexPath).not.toBeNull();
expect(await canonicalPath(indexPath ?? "")).toBe(
await canonicalPath(path.join(realPkg, "dist", "control-ui", "index.html")),
);
});
// moduleUrl candidate: <moduleDir>/control-ui
const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "bundle.js")).toString();
expect(resolveControlUiRootSync({ moduleUrl })).toBe(uiDir);
});
});

View File

@@ -3,7 +3,7 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js";
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
@@ -67,6 +67,13 @@ describe("gateway lock", () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-"));
});
beforeEach(() => {
// Other suites occasionally leave global spies behind (Date.now, setTimeout, etc.).
// This test relies on fake timers advancing Date.now and setTimeout deterministically.
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
@@ -76,30 +83,45 @@ describe("gateway lock", () => {
});
it("blocks concurrent acquisition until release", async () => {
vi.useRealTimers();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z"));
const { env, cleanup } = await makeEnv();
const lock = await acquireGatewayLock({
env,
allowInTests: true,
timeoutMs: 80,
pollIntervalMs: 5,
timeoutMs: 20,
pollIntervalMs: 1,
});
expect(lock).not.toBeNull();
let settled = false;
const pending = acquireGatewayLock({
env,
allowInTests: true,
timeoutMs: 80,
pollIntervalMs: 5,
timeoutMs: 20,
pollIntervalMs: 1,
});
void pending.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
// Drive the retry loop without real sleeping.
for (let i = 0; i < 20 && !settled; i += 1) {
await vi.advanceTimersByTimeAsync(5);
await Promise.resolve();
}
await expect(pending).rejects.toBeInstanceOf(GatewayLockError);
await lock?.release();
const lock2 = await acquireGatewayLock({
env,
allowInTests: true,
timeoutMs: 80,
pollIntervalMs: 5,
timeoutMs: 20,
pollIntervalMs: 1,
});
await lock2?.release();
await cleanup();
@@ -107,6 +129,7 @@ describe("gateway lock", () => {
it("treats recycled linux pid as stale when start time mismatches", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z"));
const { env, cleanup } = await makeEnv();
const { lockPath, configPath } = resolveLockPath(env);
const payload = {

View File

@@ -1,24 +1,20 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js";
import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js";
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
import * as replyModule from "../auto-reply/reply.js";
import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js";
import {
resolveAgentIdFromSessionKey,
resolveAgentMainSessionKey,
resolveMainSessionKey,
resolveStorePath,
} from "../config/sessions.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime } from "../plugins/runtime/index.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import {
isHeartbeatEnabledForAgent,
resolveHeartbeatIntervalMs,
@@ -33,16 +29,90 @@ import {
// Avoid pulling optional runtime deps during isolated runs.
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
let previousRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
let testRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
let fixtureRoot = "";
let fixtureCount = 0;
const createCaseDir = async (prefix: string) => {
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
beforeAll(async () => {
previousRegistry = getActivePluginRegistry();
const whatsappPlugin = createOutboundTestPlugin({ id: "whatsapp", outbound: whatsappOutbound });
whatsappPlugin.config = {
...whatsappPlugin.config,
resolveAllowFrom: ({ cfg }) =>
cfg.channels?.whatsapp?.allowFrom?.map((entry) => String(entry)) ?? [],
};
const telegramPlugin = createOutboundTestPlugin({
id: "telegram",
outbound: {
deliveryMode: "direct",
sendText: async ({ to, text, deps, accountId }) => {
if (!deps?.sendTelegram) {
throw new Error("sendTelegram missing");
}
const res = await deps.sendTelegram(to, text, {
verbose: false,
accountId: accountId ?? undefined,
});
return { channel: "telegram", messageId: res.messageId, chatId: res.chatId };
},
sendMedia: async ({ to, text, mediaUrl, deps, accountId }) => {
if (!deps?.sendTelegram) {
throw new Error("sendTelegram missing");
}
const res = await deps.sendTelegram(to, text, {
verbose: false,
accountId: accountId ?? undefined,
mediaUrl,
});
return { channel: "telegram", messageId: res.messageId, chatId: res.chatId };
},
},
});
telegramPlugin.config = {
...telegramPlugin.config,
listAccountIds: (cfg) => Object.keys(cfg.channels?.telegram?.accounts ?? {}),
resolveAllowFrom: ({ cfg, accountId }) => {
const channel = cfg.channels?.telegram;
const normalized = accountId?.trim();
if (normalized && channel?.accounts?.[normalized]?.allowFrom) {
return channel.accounts[normalized].allowFrom?.map((entry) => String(entry)) ?? [];
}
return channel?.allowFrom?.map((entry) => String(entry)) ?? [];
},
};
testRegistry = createTestRegistry([
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
]);
setActivePluginRegistry(testRegistry);
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-heartbeat-suite-"));
});
beforeEach(() => {
const runtime = createPluginRuntime();
setTelegramRuntime(runtime);
setWhatsAppRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
]),
);
if (testRegistry) {
setActivePluginRegistry(testRegistry);
}
});
afterAll(async () => {
if (fixtureRoot) {
await fs.rm(fixtureRoot, { recursive: true, force: true });
}
if (previousRegistry) {
setActivePluginRegistry(previousRegistry);
}
});
describe("resolveHeartbeatIntervalMs", () => {
@@ -397,7 +467,7 @@ describe("runHeartbeatOnce", () => {
});
it("uses the last non-empty payload for delivery", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const tmpDir = await createCaseDir("hb-last-payload");
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
@@ -415,18 +485,14 @@ describe("runHeartbeatOnce", () => {
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
JSON.stringify({
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
null,
2,
),
}),
);
replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]);
@@ -450,12 +516,11 @@ describe("runHeartbeatOnce", () => {
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object));
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("uses per-agent heartbeat overrides and session keys", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const tmpDir = await createCaseDir("hb-agent-overrides");
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
@@ -479,18 +544,14 @@ describe("runHeartbeatOnce", () => {
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
JSON.stringify({
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
null,
2,
),
}),
);
replySpy.mockResolvedValue([{ text: "Final alert" }]);
const sendWhatsApp = vi.fn().mockResolvedValue({
@@ -520,12 +581,11 @@ describe("runHeartbeatOnce", () => {
);
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("reuses non-default agent sessionFile from templated stores", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const tmpDir = await createCaseDir("hb-templated-store");
const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions", "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
const agentId = "ops";
@@ -598,12 +658,11 @@ describe("runHeartbeatOnce", () => {
);
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("runs heartbeats in the explicit session key when configured", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const tmpDir = await createCaseDir("hb-explicit-session");
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
@@ -635,24 +694,20 @@ describe("runHeartbeatOnce", () => {
await fs.writeFile(
storePath,
JSON.stringify(
{
[mainSessionKey]: {
sessionId: "sid-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
[groupSessionKey]: {
sessionId: "sid-group",
updatedAt: Date.now() + 10_000,
lastChannel: "whatsapp",
lastTo: groupId,
},
JSON.stringify({
[mainSessionKey]: {
sessionId: "sid-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
null,
2,
),
[groupSessionKey]: {
sessionId: "sid-group",
updatedAt: Date.now() + 10_000,
lastChannel: "whatsapp",
lastTo: groupId,
},
}),
);
replySpy.mockResolvedValue([{ text: "Group alert" }]);
@@ -681,12 +736,11 @@ describe("runHeartbeatOnce", () => {
);
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("suppresses duplicate heartbeat payloads within 24h", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const tmpDir = await createCaseDir("hb-dup-suppress");
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
@@ -704,20 +758,16 @@ describe("runHeartbeatOnce", () => {
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastHeartbeatText: "Final alert",
lastHeartbeatSentAt: 0,
},
JSON.stringify({
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastHeartbeatText: "Final alert",
lastHeartbeatSentAt: 0,
},
null,
2,
),
}),
);
replySpy.mockResolvedValue([{ text: "Final alert" }]);
@@ -737,12 +787,11 @@ describe("runHeartbeatOnce", () => {
expect(sendWhatsApp).toHaveBeenCalledTimes(0);
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("can include reasoning payloads when enabled", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const tmpDir = await createCaseDir("hb-reasoning");
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
@@ -764,19 +813,15 @@ describe("runHeartbeatOnce", () => {
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp",
lastTo: "+1555",
},
JSON.stringify({
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp",
lastTo: "+1555",
},
null,
2,
),
}),
);
replySpy.mockResolvedValue([
@@ -809,12 +854,11 @@ describe("runHeartbeatOnce", () => {
expect(sendWhatsApp).toHaveBeenNthCalledWith(2, "+1555", "Final alert", expect.any(Object));
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("delivers reasoning even when the main heartbeat reply is HEARTBEAT_OK", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const tmpDir = await createCaseDir("hb-reasoning-heartbeat-ok");
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
@@ -836,19 +880,15 @@ describe("runHeartbeatOnce", () => {
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp",
lastTo: "+1555",
},
JSON.stringify({
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp",
lastTo: "+1555",
},
null,
2,
),
}),
);
replySpy.mockResolvedValue([
@@ -880,7 +920,6 @@ describe("runHeartbeatOnce", () => {
);
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,150 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { beforeEach, describe, expect, it, vi } from "vitest";
type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
const VITEST_FS_BASE = path.join(path.parse(process.cwd()).root, "__openclaw_vitest__");
const FIXTURE_BASE = path.join(VITEST_FS_BASE, "openclaw-root");
const state = vi.hoisted(() => ({
entries: new Map<string, FakeFsEntry>(),
realpaths: new Map<string, string>(),
}));
const abs = (p: string) => path.resolve(p);
const fx = (...parts: string[]) => path.join(FIXTURE_BASE, ...parts);
function setFile(p: string, content = "") {
state.entries.set(abs(p), { kind: "file", content });
}
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const pathMod = await import("node:path");
const absInMock = (p: string) => pathMod.resolve(p);
const vitestRoot = `${absInMock(VITEST_FS_BASE)}${pathMod.sep}`;
const isFixturePath = (p: string) => {
const resolved = absInMock(p);
return resolved === vitestRoot.slice(0, -1) || resolved.startsWith(vitestRoot);
};
const wrapped = {
...actual,
existsSync: (p: string) =>
isFixturePath(p) ? state.entries.has(absInMock(p)) : actual.existsSync(p),
readFileSync: (p: string, encoding?: unknown) => {
if (!isFixturePath(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return actual.readFileSync(p as any, encoding as any) as unknown;
}
const entry = state.entries.get(absInMock(p));
if (!entry || entry.kind !== "file") {
throw new Error(`ENOENT: no such file, open '${p}'`);
}
return encoding ? entry.content : Buffer.from(entry.content, "utf-8");
},
statSync: (p: string) => {
if (!isFixturePath(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return actual.statSync(p as any) as unknown;
}
const entry = state.entries.get(absInMock(p));
if (!entry) {
throw new Error(`ENOENT: no such file or directory, stat '${p}'`);
}
return {
isFile: () => entry.kind === "file",
isDirectory: () => entry.kind === "dir",
};
},
realpathSync: (p: string) =>
isFixturePath(p)
? (state.realpaths.get(absInMock(p)) ?? absInMock(p))
: actual.realpathSync(p),
};
return { ...wrapped, default: wrapped };
});
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
const pathMod = await import("node:path");
const absInMock = (p: string) => pathMod.resolve(p);
const vitestRoot = `${absInMock(VITEST_FS_BASE)}${pathMod.sep}`;
const isFixturePath = (p: string) => {
const resolved = absInMock(p);
return resolved === vitestRoot.slice(0, -1) || resolved.startsWith(vitestRoot);
};
const wrapped = {
...actual,
readFile: async (p: string, encoding?: unknown) => {
if (!isFixturePath(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (await actual.readFile(p as any, encoding as any)) as unknown;
}
const entry = state.entries.get(absInMock(p));
if (!entry || entry.kind !== "file") {
throw new Error(`ENOENT: no such file, open '${p}'`);
}
return entry.content;
},
};
return { ...wrapped, default: wrapped };
});
describe("resolveOpenClawPackageRoot", () => {
beforeEach(() => {
state.entries.clear();
state.realpaths.clear();
});
it("resolves package root from .bin argv1", async () => {
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
const project = fx("bin-scenario");
const argv1 = path.join(project, "node_modules", ".bin", "openclaw");
const pkgRoot = path.join(project, "node_modules", "openclaw");
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
expect(resolveOpenClawPackageRootSync({ argv1 })).toBe(pkgRoot);
});
it("resolves package root via symlinked argv1", async () => {
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
const project = fx("symlink-scenario");
const bin = path.join(project, "bin", "openclaw");
const realPkg = path.join(project, "real-pkg");
state.realpaths.set(abs(bin), abs(path.join(realPkg, "openclaw.mjs")));
setFile(path.join(realPkg, "package.json"), JSON.stringify({ name: "openclaw" }));
expect(resolveOpenClawPackageRootSync({ argv1: bin })).toBe(realPkg);
});
it("prefers moduleUrl candidates", async () => {
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
const pkgRoot = fx("moduleurl");
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "index.js")).toString();
expect(resolveOpenClawPackageRootSync({ moduleUrl })).toBe(pkgRoot);
});
it("returns null for non-openclaw package roots", async () => {
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
const pkgRoot = fx("not-openclaw");
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" }));
expect(resolveOpenClawPackageRootSync({ cwd: pkgRoot })).toBeNull();
});
it("async resolver matches sync behavior", async () => {
const { resolveOpenClawPackageRoot } = await import("./openclaw-root.js");
const pkgRoot = fx("async");
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
await expect(resolveOpenClawPackageRoot({ cwd: pkgRoot })).resolves.toBe(pkgRoot);
});
});

View File

@@ -1,237 +1,215 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ensureOpenClawCliOnPath } from "./path-env.js";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
dirs: new Set<string>(),
executables: new Set<string>(),
}));
const abs = (p: string) => path.resolve(p);
const setDir = (p: string) => state.dirs.add(abs(p));
const setExe = (p: string) => state.executables.add(abs(p));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const pathMod = await import("node:path");
const absInMock = (p: string) => pathMod.resolve(p);
const wrapped = {
...actual,
constants: { ...actual.constants, X_OK: actual.constants.X_OK ?? 1 },
accessSync: (p: string, mode?: number) => {
// `mode` is ignored in tests; we only model "is executable" or "not".
if (!state.executables.has(absInMock(p))) {
throw new Error(`EACCES: permission denied, access '${p}' (mode=${mode ?? 0})`);
}
},
statSync: (p: string) => ({
// Avoid throws for non-existent paths; the code under test only cares about isDirectory().
isDirectory: () => state.dirs.has(absInMock(p)),
}),
};
return { ...wrapped, default: wrapped };
});
let ensureOpenClawCliOnPath: typeof import("./path-env.js").ensureOpenClawCliOnPath;
describe("ensureOpenClawCliOnPath", () => {
let fixtureRoot = "";
let fixtureCount = 0;
async function makeTmpDir(): Promise<string> {
const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`);
await fs.mkdir(tmp);
return tmp;
}
const envKeys = [
"PATH",
"OPENCLAW_PATH_BOOTSTRAPPED",
"OPENCLAW_ALLOW_PROJECT_LOCAL_BIN",
"MISE_DATA_DIR",
"HOMEBREW_PREFIX",
"HOMEBREW_BREW_FILE",
"XDG_BIN_HOME",
] as const;
let envSnapshot: Record<(typeof envKeys)[number], string | undefined>;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-path-"));
({ ensureOpenClawCliOnPath } = await import("./path-env.js"));
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
beforeEach(() => {
envSnapshot = Object.fromEntries(envKeys.map((k) => [k, process.env[k]])) as typeof envSnapshot;
state.dirs.clear();
state.executables.clear();
setDir("/usr/bin");
setDir("/bin");
vi.clearAllMocks();
});
it("prepends the bundled app bin dir when a sibling openclaw exists", async () => {
const tmp = await makeTmpDir();
const appBinDir = path.join(tmp, "AppBin");
await fs.mkdir(appBinDir);
const cliPath = path.join(appBinDir, "openclaw");
await fs.writeFile(cliPath, "#!/bin/sh\necho ok\n", "utf-8");
await fs.chmod(cliPath, 0o755);
const originalPath = process.env.PATH;
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
try {
ensureOpenClawCliOnPath({
execPath: cliPath,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
const updated = process.env.PATH ?? "";
expect(updated.split(path.delimiter)[0]).toBe(appBinDir);
} finally {
process.env.PATH = originalPath;
if (originalFlag === undefined) {
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
afterEach(() => {
for (const k of envKeys) {
const value = envSnapshot[k];
if (value === undefined) {
delete process.env[k];
} else {
process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag;
process.env[k] = value;
}
}
});
it("prepends the bundled app bin dir when a sibling openclaw exists", () => {
const tmp = abs("/tmp/openclaw-path/case-bundled");
const appBinDir = path.join(tmp, "AppBin");
const cliPath = path.join(appBinDir, "openclaw");
setDir(tmp);
setDir(appBinDir);
setExe(cliPath);
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
ensureOpenClawCliOnPath({
execPath: cliPath,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
const updated = process.env.PATH ?? "";
expect(updated.split(path.delimiter)[0]).toBe(appBinDir);
});
it("is idempotent", () => {
const originalPath = process.env.PATH;
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
process.env.PATH = "/bin";
process.env.OPENCLAW_PATH_BOOTSTRAPPED = "1";
try {
ensureOpenClawCliOnPath({
execPath: "/tmp/does-not-matter",
cwd: "/tmp",
homeDir: "/tmp",
platform: "darwin",
});
expect(process.env.PATH).toBe("/bin");
} finally {
process.env.PATH = originalPath;
if (originalFlag === undefined) {
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
} else {
process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag;
}
}
ensureOpenClawCliOnPath({
execPath: "/tmp/does-not-matter",
cwd: "/tmp",
homeDir: "/tmp",
platform: "darwin",
});
expect(process.env.PATH).toBe("/bin");
});
it("prepends mise shims when available", async () => {
const tmp = await makeTmpDir();
const originalPath = process.env.PATH;
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
const originalMiseDataDir = process.env.MISE_DATA_DIR;
try {
const appBinDir = path.join(tmp, "AppBin");
await fs.mkdir(appBinDir);
const appCli = path.join(appBinDir, "openclaw");
await fs.writeFile(appCli, "#!/bin/sh\necho ok\n", "utf-8");
await fs.chmod(appCli, 0o755);
it("prepends mise shims when available", () => {
const tmp = abs("/tmp/openclaw-path/case-mise");
const appBinDir = path.join(tmp, "AppBin");
const appCli = path.join(appBinDir, "openclaw");
setDir(tmp);
setDir(appBinDir);
setExe(appCli);
const miseDataDir = path.join(tmp, "mise");
const shimsDir = path.join(miseDataDir, "shims");
await fs.mkdir(shimsDir, { recursive: true });
process.env.MISE_DATA_DIR = miseDataDir;
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
const miseDataDir = path.join(tmp, "mise");
const shimsDir = path.join(miseDataDir, "shims");
setDir(miseDataDir);
setDir(shimsDir);
ensureOpenClawCliOnPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
process.env.MISE_DATA_DIR = miseDataDir;
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
const updated = process.env.PATH ?? "";
const parts = updated.split(path.delimiter);
const appBinIndex = parts.indexOf(appBinDir);
const shimsIndex = parts.indexOf(shimsDir);
expect(appBinIndex).toBeGreaterThanOrEqual(0);
expect(shimsIndex).toBeGreaterThan(appBinIndex);
} finally {
process.env.PATH = originalPath;
if (originalFlag === undefined) {
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
} else {
process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag;
}
if (originalMiseDataDir === undefined) {
delete process.env.MISE_DATA_DIR;
} else {
process.env.MISE_DATA_DIR = originalMiseDataDir;
}
}
ensureOpenClawCliOnPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
const updated = process.env.PATH ?? "";
const parts = updated.split(path.delimiter);
const appBinIndex = parts.indexOf(appBinDir);
const shimsIndex = parts.indexOf(shimsDir);
expect(appBinIndex).toBeGreaterThanOrEqual(0);
expect(shimsIndex).toBeGreaterThan(appBinIndex);
});
it("only appends project-local node_modules/.bin when explicitly enabled", async () => {
const tmp = await makeTmpDir();
const originalPath = process.env.PATH;
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
try {
const appBinDir = path.join(tmp, "AppBin");
await fs.mkdir(appBinDir);
const appCli = path.join(appBinDir, "openclaw");
await fs.writeFile(appCli, "#!/bin/sh\necho ok\n", "utf-8");
await fs.chmod(appCli, 0o755);
it("only appends project-local node_modules/.bin when explicitly enabled", () => {
const tmp = abs("/tmp/openclaw-path/case-project-local");
const appBinDir = path.join(tmp, "AppBin");
const appCli = path.join(appBinDir, "openclaw");
setDir(tmp);
setDir(appBinDir);
setExe(appCli);
const localBinDir = path.join(tmp, "node_modules", ".bin");
await fs.mkdir(localBinDir, { recursive: true });
const localCli = path.join(localBinDir, "openclaw");
await fs.writeFile(localCli, "#!/bin/sh\necho ok\n", "utf-8");
await fs.chmod(localCli, 0o755);
const localBinDir = path.join(tmp, "node_modules", ".bin");
const localCli = path.join(localBinDir, "openclaw");
setDir(path.join(tmp, "node_modules"));
setDir(localBinDir);
setExe(localCli);
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
ensureOpenClawCliOnPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
const withoutOptIn = (process.env.PATH ?? "").split(path.delimiter);
expect(withoutOptIn.includes(localBinDir)).toBe(false);
ensureOpenClawCliOnPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
});
const withoutOptIn = (process.env.PATH ?? "").split(path.delimiter);
expect(withoutOptIn.includes(localBinDir)).toBe(false);
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
ensureOpenClawCliOnPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
allowProjectLocalBin: true,
});
const withOptIn = (process.env.PATH ?? "").split(path.delimiter);
const usrBinIndex = withOptIn.indexOf("/usr/bin");
const localIndex = withOptIn.indexOf(localBinDir);
expect(usrBinIndex).toBeGreaterThanOrEqual(0);
expect(localIndex).toBeGreaterThan(usrBinIndex);
} finally {
process.env.PATH = originalPath;
if (originalFlag === undefined) {
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
} else {
process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag;
}
}
ensureOpenClawCliOnPath({
execPath: appCli,
cwd: tmp,
homeDir: tmp,
platform: "darwin",
allowProjectLocalBin: true,
});
const withOptIn = (process.env.PATH ?? "").split(path.delimiter);
const usrBinIndex = withOptIn.indexOf("/usr/bin");
const localIndex = withOptIn.indexOf(localBinDir);
expect(usrBinIndex).toBeGreaterThanOrEqual(0);
expect(localIndex).toBeGreaterThan(usrBinIndex);
});
it("prepends Linuxbrew dirs when present", async () => {
const tmp = await makeTmpDir();
const originalPath = process.env.PATH;
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
const originalHomebrewPrefix = process.env.HOMEBREW_PREFIX;
const originalHomebrewBrewFile = process.env.HOMEBREW_BREW_FILE;
const originalXdgBinHome = process.env.XDG_BIN_HOME;
try {
const execDir = path.join(tmp, "exec");
await fs.mkdir(execDir);
it("prepends Linuxbrew dirs when present", () => {
const tmp = abs("/tmp/openclaw-path/case-linuxbrew");
const execDir = path.join(tmp, "exec");
setDir(tmp);
setDir(execDir);
const linuxbrewBin = path.join(tmp, ".linuxbrew", "bin");
const linuxbrewSbin = path.join(tmp, ".linuxbrew", "sbin");
await fs.mkdir(linuxbrewBin, { recursive: true });
await fs.mkdir(linuxbrewSbin, { recursive: true });
const linuxbrewDir = path.join(tmp, ".linuxbrew");
const linuxbrewBin = path.join(linuxbrewDir, "bin");
const linuxbrewSbin = path.join(linuxbrewDir, "sbin");
setDir(linuxbrewDir);
setDir(linuxbrewBin);
setDir(linuxbrewSbin);
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
delete process.env.HOMEBREW_PREFIX;
delete process.env.HOMEBREW_BREW_FILE;
delete process.env.XDG_BIN_HOME;
process.env.PATH = "/usr/bin";
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
delete process.env.HOMEBREW_PREFIX;
delete process.env.HOMEBREW_BREW_FILE;
delete process.env.XDG_BIN_HOME;
ensureOpenClawCliOnPath({
execPath: path.join(execDir, "node"),
cwd: tmp,
homeDir: tmp,
platform: "linux",
});
ensureOpenClawCliOnPath({
execPath: path.join(execDir, "node"),
cwd: tmp,
homeDir: tmp,
platform: "linux",
});
const updated = process.env.PATH ?? "";
const parts = updated.split(path.delimiter);
expect(parts[0]).toBe(linuxbrewBin);
expect(parts[1]).toBe(linuxbrewSbin);
} finally {
process.env.PATH = originalPath;
if (originalFlag === undefined) {
delete process.env.OPENCLAW_PATH_BOOTSTRAPPED;
} else {
process.env.OPENCLAW_PATH_BOOTSTRAPPED = originalFlag;
}
if (originalHomebrewPrefix === undefined) {
delete process.env.HOMEBREW_PREFIX;
} else {
process.env.HOMEBREW_PREFIX = originalHomebrewPrefix;
}
if (originalHomebrewBrewFile === undefined) {
delete process.env.HOMEBREW_BREW_FILE;
} else {
process.env.HOMEBREW_BREW_FILE = originalHomebrewBrewFile;
}
if (originalXdgBinHome === undefined) {
delete process.env.XDG_BIN_HOME;
} else {
process.env.XDG_BIN_HOME = originalXdgBinHome;
}
}
const updated = process.env.PATH ?? "";
const parts = updated.split(path.delimiter);
expect(parts[0]).toBe(linuxbrewBin);
expect(parts[1]).toBe(linuxbrewSbin);
});
});

View File

@@ -1,12 +1,71 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resolveProviderAuths } from "./provider-usage.auth.js";
describe("resolveProviderAuths key normalization", () => {
let suiteRoot = "";
let suiteCase = 0;
beforeAll(async () => {
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-auth-suite-"));
});
afterAll(async () => {
await fs.rm(suiteRoot, { recursive: true, force: true });
suiteRoot = "";
suiteCase = 0;
});
async function withSuiteHome<T>(
fn: (home: string) => Promise<T>,
env: Record<string, string | undefined>,
): Promise<T> {
const base = path.join(suiteRoot, `case-${++suiteCase}`);
await fs.mkdir(base, { recursive: true });
await fs.mkdir(path.join(base, ".openclaw", "agents", "main", "sessions"), { recursive: true });
const keysToRestore = new Set<string>([
"HOME",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
...Object.keys(env),
]);
const snapshot: Record<string, string | undefined> = {};
for (const key of keysToRestore) {
snapshot[key] = process.env[key];
}
process.env.HOME = base;
process.env.USERPROFILE = base;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(base, ".openclaw");
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
return await fn(base);
} finally {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
it("strips embedded CR/LF from env keys", async () => {
await withTempHome(
await withSuiteHome(
async () => {
const auths = await resolveProviderAuths({
providers: ["zai", "minimax", "xiaomi"],
@@ -18,17 +77,15 @@ describe("resolveProviderAuths key normalization", () => {
]);
},
{
env: {
ZAI_API_KEY: "zai-\r\nkey",
MINIMAX_API_KEY: "minimax-\r\nkey",
XIAOMI_API_KEY: "xiaomi-\r\nkey",
},
ZAI_API_KEY: "zai-\r\nkey",
MINIMAX_API_KEY: "minimax-\r\nkey",
XIAOMI_API_KEY: "xiaomi-\r\nkey",
},
);
});
it("strips embedded CR/LF from stored auth profiles (token + api_key)", async () => {
await withTempHome(
await withSuiteHome(
async (home) => {
const agentDir = path.join(home, ".openclaw", "agents", "main", "agent");
await fs.mkdir(agentDir, { recursive: true });
@@ -57,11 +114,9 @@ describe("resolveProviderAuths key normalization", () => {
]);
},
{
env: {
MINIMAX_API_KEY: undefined,
MINIMAX_CODE_PLAN_KEY: undefined,
XIAOMI_API_KEY: undefined,
},
MINIMAX_API_KEY: undefined,
MINIMAX_CODE_PLAN_KEY: undefined,
XIAOMI_API_KEY: undefined,
},
);
});

View File

@@ -1,6 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { waitForTransportReady } from "./transport-ready.js";
// Perf: `sleepWithAbort` uses `node:timers/promises` which isn't controlled by fake timers.
// Route sleeps through global `setTimeout` so tests can advance time deterministically.
vi.mock("./backoff.js", () => ({
sleepWithAbort: async (ms: number) => {
if (ms <= 0) {
return;
}
await new Promise<void>((resolve) => setTimeout(resolve, ms));
},
}));
describe("waitForTransportReady", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -16,7 +27,8 @@ describe("waitForTransportReady", () => {
const readyPromise = waitForTransportReady({
label: "test transport",
timeoutMs: 220,
logAfterMs: 60,
// Deterministic: first attempt at t=0 won't log; second attempt at t=50 will.
logAfterMs: 1,
logIntervalMs: 1_000,
pollIntervalMs: 50,
runtime,
@@ -29,9 +41,7 @@ describe("waitForTransportReady", () => {
},
});
for (let i = 0; i < 3; i += 1) {
await vi.advanceTimersByTimeAsync(50);
}
await vi.advanceTimersByTimeAsync(200);
await readyPromise;
expect(runtime.error).toHaveBeenCalled();
@@ -48,8 +58,9 @@ describe("waitForTransportReady", () => {
runtime,
check: async () => ({ ok: false, error: "still down" }),
});
const asserted = expect(waitPromise).rejects.toThrow("test transport not ready");
await vi.advanceTimersByTimeAsync(200);
await expect(waitPromise).rejects.toThrow("test transport not ready");
await asserted;
expect(runtime.error).toHaveBeenCalled();
});

View File

@@ -46,10 +46,8 @@ describe("memory index", () => {
let workspaceDir = "";
let memoryDir = "";
let extraDir = "";
let indexBasicPath = "";
let indexCachePath = "";
let indexHybridPath = "";
let indexVectorPath = "";
let indexMainPath = "";
let indexExtraPath = "";
// Perf: keep managers open across tests, but only reset the one a test uses.
@@ -61,13 +59,15 @@ describe("memory index", () => {
workspaceDir = path.join(fixtureRoot, "workspace");
memoryDir = path.join(workspaceDir, "memory");
extraDir = path.join(workspaceDir, "extra");
indexBasicPath = path.join(workspaceDir, "index-basic.sqlite");
indexCachePath = path.join(workspaceDir, "index-cache.sqlite");
indexHybridPath = path.join(workspaceDir, "index-hybrid.sqlite");
indexMainPath = path.join(workspaceDir, "index-main.sqlite");
indexVectorPath = path.join(workspaceDir, "index-vector.sqlite");
indexExtraPath = path.join(workspaceDir, "index-extra.sqlite");
await fs.mkdir(memoryDir, { recursive: true });
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
);
});
afterAll(async () => {
@@ -83,10 +83,6 @@ describe("memory index", () => {
// Keep the workspace stable to allow manager reuse across tests.
await fs.mkdir(memoryDir, { recursive: true });
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
);
// Clean additional paths that may have been created by earlier cases.
await fs.rm(extraDir, { recursive: true, force: true });
@@ -105,6 +101,38 @@ describe("memory index", () => {
type TestCfg = Parameters<typeof getMemorySearchManager>[0]["cfg"];
function createCfg(params: {
storePath: string;
extraPaths?: string[];
model?: string;
vectorEnabled?: boolean;
cacheEnabled?: boolean;
hybrid?: { enabled: boolean; vectorWeight?: number; textWeight?: number };
}): TestCfg {
return {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: params.model ?? "mock-embed",
store: { path: params.storePath, vector: { enabled: params.vectorEnabled ?? false } },
// Perf: keep test indexes to a single chunk to reduce sqlite work.
chunking: { tokens: 4000, overlap: 0 },
sync: { watch: false, onSessionStart: false, onSearch: true },
query: {
minScore: 0,
hybrid: params.hybrid ?? { enabled: false },
},
cache: params.cacheEnabled ? { enabled: true } : undefined,
extraPaths: params.extraPaths,
},
},
list: [{ id: "main", default: true }],
},
};
}
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
const storePath = cfg.agents?.defaults?.memorySearch?.store?.path;
if (!storePath) {
@@ -128,24 +156,14 @@ describe("memory index", () => {
return manager;
}
it("indexes memory files and searches by vector", async () => {
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexBasicPath, vector: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: true },
query: { minScore: 0, hybrid: { enabled: false } },
},
},
list: [{ id: "main", default: true }],
},
};
it("indexes memory files and searches", async () => {
const cfg = createCfg({
storePath: indexMainPath,
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
});
const manager = await getPersistentManager(cfg);
await manager.sync({ reason: "test" });
expect(embedBatchCalls).toBeGreaterThan(0);
const results = await manager.search("alpha");
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.path).toContain("memory/2026-01-12.md");
@@ -162,26 +180,8 @@ describe("memory index", () => {
});
it("keeps dirty false in status-only manager after prior indexing", async () => {
const indexStatusPath = path.join(workspaceDir, "index-status.sqlite");
await fs.rm(indexStatusPath, { force: true });
await fs.rm(`${indexStatusPath}-shm`, { force: true });
await fs.rm(`${indexStatusPath}-wal`, { force: true });
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexStatusPath, vector: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: true },
query: { minScore: 0, hybrid: { enabled: false } },
},
},
list: [{ id: "main", default: true }],
},
};
const indexStatusPath = path.join(workspaceDir, `index-status-${Date.now()}.sqlite`);
const cfg = createCfg({ storePath: indexStatusPath });
const first = await getMemorySearchManager({ cfg, agentId: "main" });
expect(first.manager).not.toBeNull();
@@ -207,25 +207,8 @@ describe("memory index", () => {
});
it("reindexes when the embedding model changes", async () => {
const indexModelPath = path.join(workspaceDir, "index-model-change.sqlite");
await fs.rm(indexModelPath, { force: true });
await fs.rm(`${indexModelPath}-shm`, { force: true });
await fs.rm(`${indexModelPath}-wal`, { force: true });
const base = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
store: { path: indexModelPath },
sync: { watch: false, onSessionStart: false, onSearch: true },
query: { minScore: 0, hybrid: { enabled: false } },
},
},
list: [{ id: "main", default: true }],
},
};
const indexModelPath = path.join(workspaceDir, `index-model-change-${Date.now()}.sqlite`);
const base = createCfg({ storePath: indexModelPath });
const first = await getMemorySearchManager({
cfg: {
@@ -279,24 +262,11 @@ describe("memory index", () => {
});
it("reuses cached embeddings on forced reindex", async () => {
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexCachePath, vector: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: false },
query: { minScore: 0, hybrid: { enabled: false } },
cache: { enabled: true },
},
},
list: [{ id: "main", default: true }],
},
};
const cfg = createCfg({ storePath: indexMainPath, cacheEnabled: true });
const manager = await getPersistentManager(cfg);
await manager.sync({ force: true });
// Seed the embedding cache once, then ensure a forced reindex doesn't
// re-embed when the cache is enabled.
await manager.sync({ reason: "test" });
const afterFirst = embedBatchCalls;
expect(afterFirst).toBeGreaterThan(0);
@@ -305,24 +275,10 @@ describe("memory index", () => {
});
it("finds keyword matches via hybrid search when query embedding is zero", async () => {
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexHybridPath, vector: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: true },
query: {
minScore: 0,
hybrid: { enabled: true, vectorWeight: 0, textWeight: 1 },
},
},
},
list: [{ id: "main", default: true }],
},
};
const cfg = createCfg({
storePath: indexMainPath,
hybrid: { enabled: true, vectorWeight: 0, textWeight: 1 },
});
const manager = await getPersistentManager(cfg);
const status = manager.status();
@@ -337,20 +293,7 @@ describe("memory index", () => {
});
it("reports vector availability after probe", async () => {
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexVectorPath, vector: { enabled: true } },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},
list: [{ id: "main", default: true }],
},
};
const cfg = createCfg({ storePath: indexVectorPath, vectorEnabled: true });
const manager = await getPersistentManager(cfg);
const available = await manager.probeVectorAvailability();
const status = manager.status();
@@ -360,21 +303,7 @@ describe("memory index", () => {
});
it("rejects reading non-memory paths", async () => {
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexBasicPath, vector: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: true },
query: { minScore: 0, hybrid: { enabled: false } },
},
},
list: [{ id: "main", default: true }],
},
};
const cfg = createCfg({ storePath: indexMainPath });
const manager = await getPersistentManager(cfg);
await expect(manager.readFile({ relPath: "NOTES.md" })).rejects.toThrow("path required");
});
@@ -383,22 +312,7 @@ describe("memory index", () => {
await fs.mkdir(extraDir, { recursive: true });
await fs.writeFile(path.join(extraDir, "extra.md"), "Extra content.");
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexExtraPath, vector: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: true },
query: { minScore: 0, hybrid: { enabled: false } },
extraPaths: [extraDir],
},
},
list: [{ id: "main", default: true }],
},
};
const cfg = createCfg({ storePath: indexExtraPath, extraPaths: [extraDir] });
const manager = await getPersistentManager(cfg);
await expect(manager.readFile({ relPath: "extra/extra.md" })).resolves.toEqual({
path: "extra/extra.md",

View File

@@ -73,10 +73,19 @@ describe("memory search async sync", () => {
const pending = new Promise<void>(() => {});
(manager as unknown as { sync: () => Promise<void> }).sync = vi.fn(async () => pending);
const resolved = await Promise.race([
manager.search("hello").then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 1000)),
]);
const resolved = await new Promise<boolean>((resolve, reject) => {
const timeout = setTimeout(() => resolve(false), 1000);
void manager
.search("hello")
.then(() => {
clearTimeout(timeout);
resolve(true);
})
.catch((err) => {
clearTimeout(timeout);
reject(err);
});
});
expect(resolved).toBe(true);
});
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
let shouldFail = false;
@@ -34,15 +34,26 @@ vi.mock("./embeddings.js", () => {
};
});
vi.mock("./sqlite-vec.js", () => ({
loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }),
}));
describe("memory manager atomic reindex", () => {
let fixtureRoot = "";
let caseId = 0;
let workspaceDir: string;
let indexPath: string;
let manager: MemoryIndexManager | null = null;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-atomic-"));
});
beforeEach(async () => {
vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0");
shouldFail = false;
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-"));
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(workspaceDir, { recursive: true });
indexPath = path.join(workspaceDir, "index.sqlite");
await fs.mkdir(path.join(workspaceDir, "memory"));
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Hello memory.");
@@ -53,7 +64,13 @@ describe("memory manager atomic reindex", () => {
await manager.close();
manager = null;
}
await fs.rm(workspaceDir, { recursive: true, force: true });
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
it("keeps the prior index when a full reindex fails", async () => {
@@ -66,6 +83,8 @@ describe("memory manager atomic reindex", () => {
model: "mock-embed",
store: { path: indexPath },
cache: { enabled: false },
// Perf: keep test indexes to a single chunk to reduce sqlite work.
chunking: { tokens: 4000, overlap: 0 },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},
@@ -81,13 +100,13 @@ describe("memory manager atomic reindex", () => {
manager = result.manager;
await manager.sync({ force: true });
const before = await manager.search("Hello");
expect(before.length).toBeGreaterThan(0);
const beforeStatus = manager.status();
expect(beforeStatus.chunks).toBeGreaterThan(0);
shouldFail = true;
await expect(manager.sync({ force: true })).rejects.toThrow("embedding failure");
const after = await manager.search("Hello");
expect(after.length).toBeGreaterThan(0);
const afterStatus = manager.status();
expect(afterStatus.chunks).toBeGreaterThan(0);
});
});

View File

@@ -1,11 +1,10 @@
import { spawn } from "node:child_process";
import net from "node:net";
import path from "node:path";
import process from "node:process";
import { afterEach, describe, expect, it } from "vitest";
import { attachChildProcessBridge } from "./child-process-bridge.js";
function waitForLine(stream: NodeJS.ReadableStream, timeoutMs = 10_000): Promise<string> {
function waitForLine(stream: NodeJS.ReadableStream, timeoutMs = 2000): Promise<string> {
return new Promise((resolve, reject) => {
let buffer = "";
@@ -40,28 +39,6 @@ function waitForLine(stream: NodeJS.ReadableStream, timeoutMs = 10_000): Promise
});
}
function canConnect(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.createConnection({ host: "127.0.0.1", port });
socket.once("connect", () => {
socket.end();
resolve(true);
});
socket.once("error", () => resolve(false));
});
}
async function waitForPortClosed(port: number, timeoutMs = 1_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
if (!(await canConnect(port))) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error("timeout waiting for port to close");
}
describe("attachChildProcessBridge", () => {
const children: Array<{ kill: (signal?: NodeJS.Signals) => boolean }> = [];
const detachments: Array<() => void> = [];
@@ -102,11 +79,8 @@ describe("attachChildProcessBridge", () => {
if (!child.stdout) {
throw new Error("expected stdout");
}
const portLine = await waitForLine(child.stdout);
const port = Number(portLine);
expect(Number.isFinite(port)).toBe(true);
expect(await canConnect(port)).toBe(true);
const ready = await waitForLine(child.stdout);
expect(ready).toBe("ready");
// Simulate systemd sending SIGTERM to the parent process.
if (!addedSigterm) {
@@ -121,8 +95,5 @@ describe("attachChildProcessBridge", () => {
resolve();
});
});
await waitForPortClosed(port);
expect(await canConnect(port)).toBe(false);
}, 20_000);
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { collectPluginsCodeSafetyFindings } from "./audit-extra.js";
@@ -73,6 +73,26 @@ function successfulProbeResult(url: string) {
}
describe("security audit", () => {
let fixtureRoot = "";
let caseId = 0;
const makeTmpDir = async (label: string) => {
const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-"));
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined);
});
it("includes an attack surface summary (info)", async () => {
const cfg: OpenClawConfig = {
channels: { whatsapp: { groupPolicy: "open" }, telegram: { groupPolicy: "allowlist" } },
@@ -290,7 +310,7 @@ describe("security audit", () => {
});
it("treats Windows ACL-only perms as secure", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-win-"));
const tmp = await makeTmpDir("win");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const configPath = path.join(stateDir, "openclaw.json");
@@ -327,7 +347,7 @@ describe("security audit", () => {
});
it("flags Windows ACLs when Users can read the state dir", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-win-open-"));
const tmp = await makeTmpDir("win-open");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const configPath = path.join(stateDir, "openclaw.json");
@@ -831,7 +851,7 @@ describe("security audit", () => {
it("flags Discord native commands without a guild user allowlist", async () => {
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-discord-"));
const tmp = await makeTmpDir("discord");
process.env.OPENCLAW_STATE_DIR = tmp;
await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 });
try {
@@ -878,9 +898,7 @@ describe("security audit", () => {
it("does not flag Discord slash commands when dm.allowFrom includes a Discord snowflake id", async () => {
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
const tmp = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-security-audit-discord-allowfrom-snowflake-"),
);
const tmp = await makeTmpDir("discord-allowfrom-snowflake");
process.env.OPENCLAW_STATE_DIR = tmp;
await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 });
try {
@@ -927,7 +945,7 @@ describe("security audit", () => {
it("flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists", async () => {
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-discord-open-"));
const tmp = await makeTmpDir("discord-open");
process.env.OPENCLAW_STATE_DIR = tmp;
await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 });
try {
@@ -975,7 +993,7 @@ describe("security audit", () => {
it("flags Slack slash commands without a channel users allowlist", async () => {
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-slack-"));
const tmp = await makeTmpDir("slack");
process.env.OPENCLAW_STATE_DIR = tmp;
await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 });
try {
@@ -1017,7 +1035,7 @@ describe("security audit", () => {
it("flags Slack slash commands when access-group enforcement is disabled", async () => {
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-slack-open-"));
const tmp = await makeTmpDir("slack-open");
process.env.OPENCLAW_STATE_DIR = tmp;
await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 });
try {
@@ -1060,7 +1078,7 @@ describe("security audit", () => {
it("flags Telegram group commands without a sender allowlist", async () => {
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-telegram-"));
const tmp = await makeTmpDir("telegram");
process.env.OPENCLAW_STATE_DIR = tmp;
await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 });
try {
@@ -1101,9 +1119,7 @@ describe("security audit", () => {
it("warns when Telegram allowFrom entries are non-numeric (legacy @username configs)", async () => {
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
const tmp = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-security-audit-telegram-invalid-allowfrom-"),
);
const tmp = await makeTmpDir("telegram-invalid-allowfrom");
process.env.OPENCLAW_STATE_DIR = tmp;
await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 });
try {
@@ -1413,7 +1429,7 @@ describe("security audit", () => {
});
it("flags group/world-readable config include files", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-"));
const tmp = await makeTmpDir("include-perms");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
@@ -1486,7 +1502,7 @@ describe("security audit", () => {
delete process.env.TELEGRAM_BOT_TOKEN;
delete process.env.SLACK_BOT_TOKEN;
delete process.env.SLACK_APP_TOKEN;
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-"));
const tmp = await makeTmpDir("extensions-no-allowlist");
const stateDir = path.join(tmp, "state");
await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), {
recursive: true,
@@ -1533,71 +1549,63 @@ describe("security audit", () => {
});
it("flags enabled extensions when tool policy can expose plugin tools", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-plugins-"));
const tmp = await makeTmpDir("plugins-reachable");
const stateDir = path.join(tmp, "state");
await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), {
recursive: true,
mode: 0o700,
});
try {
const cfg: OpenClawConfig = {
plugins: { allow: ["some-plugin"] },
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
});
const cfg: OpenClawConfig = {
plugins: { allow: ["some-plugin"] },
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "plugins.tools_reachable_permissive_policy",
severity: "warn",
}),
]),
);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "plugins.tools_reachable_permissive_policy",
severity: "warn",
}),
]),
);
});
it("does not flag plugin tool reachability when profile is restrictive", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-plugins-"));
const tmp = await makeTmpDir("plugins-restrictive");
const stateDir = path.join(tmp, "state");
await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), {
recursive: true,
mode: 0o700,
});
try {
const cfg: OpenClawConfig = {
plugins: { allow: ["some-plugin"] },
tools: { profile: "coding" },
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
});
const cfg: OpenClawConfig = {
plugins: { allow: ["some-plugin"] },
tools: { profile: "coding" },
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
});
expect(
res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"),
).toBe(false);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
expect(
res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"),
).toBe(false);
});
it("flags unallowlisted extensions as critical when native skill commands are exposed", async () => {
const prevDiscordToken = process.env.DISCORD_BOT_TOKEN;
delete process.env.DISCORD_BOT_TOKEN;
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-"));
const tmp = await makeTmpDir("extensions-critical");
const stateDir = path.join(tmp, "state");
await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), {
recursive: true,
@@ -1636,7 +1644,7 @@ describe("security audit", () => {
});
it("flags plugins with dangerous code patterns (deep audit)", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audit-scanner-"));
const tmpDir = await makeTmpDir("audit-scanner-plugin");
const pluginDir = path.join(tmpDir, "extensions", "evil-plugin");
await fs.mkdir(path.join(pluginDir, ".hidden"), { recursive: true });
await fs.writeFile(
@@ -1675,12 +1683,10 @@ describe("security audit", () => {
(f) => f.checkId === "plugins.code_safety" && f.severity === "critical",
),
).toBe(true);
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
});
it("reports detailed code-safety issues for both plugins and skills", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audit-scanner-"));
const tmpDir = await makeTmpDir("audit-scanner-plugin-skill");
const workspaceDir = path.join(tmpDir, "workspace");
const pluginDir = path.join(tmpDir, "extensions", "evil-plugin");
const skillDir = path.join(workspaceDir, "skills", "evil-skill");
@@ -1738,12 +1744,10 @@ description: test skill
expect(skillFinding).toBeDefined();
expect(skillFinding?.detail).toContain("dangerous-exec");
expect(skillFinding?.detail).toMatch(/runner\.js:\d+/);
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
});
it("flags plugin extension entry path traversal in deep audit", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audit-scanner-"));
const tmpDir = await makeTmpDir("audit-scanner-escape");
const pluginDir = path.join(tmpDir, "extensions", "escape-plugin");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(
@@ -1755,18 +1759,8 @@ description: test skill
);
await fs.writeFile(path.join(pluginDir, "index.js"), "export {};");
const res = await runSecurityAudit({
config: {},
includeFilesystem: true,
includeChannelSecurity: false,
deep: true,
stateDir: tmpDir,
probeGatewayFn: async (opts) => successfulProbeResult(opts.url),
});
expect(res.findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true);
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir });
expect(findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true);
});
it("reports scan_failed when plugin code scanner throws during deep audit", async () => {
@@ -1774,7 +1768,7 @@ description: test skill
.spyOn(skillScanner, "scanDirectoryWithSummary")
.mockRejectedValueOnce(new Error("boom"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-audit-scanner-"));
const tmpDir = await makeTmpDir("audit-scanner-throws");
try {
const pluginDir = path.join(tmpDir, "extensions", "scanfail-plugin");
await fs.mkdir(pluginDir, { recursive: true });
@@ -1791,7 +1785,6 @@ description: test skill
expect(findings.some((f) => f.checkId === "plugins.code_safety.scan_failed")).toBe(true);
} finally {
scanSpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
}
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixSecurityFootguns } from "./fix.js";
const isWindows = process.platform === "win32";
@@ -15,10 +15,27 @@ const expectPerms = (actual: number, expected: number) => {
};
describe("security fix", () => {
let fixtureRoot = "";
let fixtureCount = 0;
const createStateDir = async (prefix: string) => {
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-suite-"));
});
afterAll(async () => {
if (fixtureRoot) {
await fs.rm(fixtureRoot, { recursive: true, force: true });
}
});
it("tightens groupPolicy + filesystem perms", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-"));
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const stateDir = await createStateDir("tightens");
await fs.chmod(stateDir, 0o755);
const configPath = path.join(stateDir, "openclaw.json");
@@ -53,10 +70,10 @@ describe("security fix", () => {
const env = {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: "",
OPENCLAW_CONFIG_PATH: configPath,
};
const res = await fixSecurityFootguns({ env });
const res = await fixSecurityFootguns({ env, stateDir, configPath });
expect(res.ok).toBe(true);
expect(res.configWritten).toBe(true);
expect(res.changes).toEqual(
@@ -88,9 +105,7 @@ describe("security fix", () => {
});
it("applies allowlist per-account and seeds WhatsApp groupAllowFrom from store", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-"));
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const stateDir = await createStateDir("per-account");
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(
@@ -122,10 +137,10 @@ describe("security fix", () => {
const env = {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: "",
OPENCLAW_CONFIG_PATH: configPath,
};
const res = await fixSecurityFootguns({ env });
const res = await fixSecurityFootguns({ env, stateDir, configPath });
expect(res.ok).toBe(true);
const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<string, unknown>;
@@ -138,9 +153,7 @@ describe("security fix", () => {
});
it("does not seed WhatsApp groupAllowFrom if allowFrom is set", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-"));
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const stateDir = await createStateDir("no-seed");
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(
@@ -168,10 +181,10 @@ describe("security fix", () => {
const env = {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: "",
OPENCLAW_CONFIG_PATH: configPath,
};
const res = await fixSecurityFootguns({ env });
const res = await fixSecurityFootguns({ env, stateDir, configPath });
expect(res.ok).toBe(true);
const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<string, unknown>;
@@ -181,9 +194,7 @@ describe("security fix", () => {
});
it("returns ok=false for invalid config but still tightens perms", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-"));
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const stateDir = await createStateDir("invalid-config");
await fs.chmod(stateDir, 0o755);
const configPath = path.join(stateDir, "openclaw.json");
@@ -193,10 +204,10 @@ describe("security fix", () => {
const env = {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: "",
OPENCLAW_CONFIG_PATH: configPath,
};
const res = await fixSecurityFootguns({ env });
const res = await fixSecurityFootguns({ env, stateDir, configPath });
expect(res.ok).toBe(false);
const stateMode = (await fs.stat(stateDir)).mode & 0o777;
@@ -207,9 +218,7 @@ describe("security fix", () => {
});
it("tightens perms for credentials + agent auth/sessions + include files", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-fix-"));
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const stateDir = await createStateDir("includes");
const includesDir = path.join(stateDir, "includes");
await fs.mkdir(includesDir, { recursive: true });
@@ -250,10 +259,10 @@ describe("security fix", () => {
const env = {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: "",
OPENCLAW_CONFIG_PATH: configPath,
};
const res = await fixSecurityFootguns({ env });
const res = await fixSecurityFootguns({ env, stateDir, configPath });
expect(res.ok).toBe(true);
expectPerms((await fs.stat(credsDir)).mode & 0o777, 0o700);

View File

@@ -1,20 +1,10 @@
import http from "node:http";
process.stdout.write("ready\n");
const server = http.createServer((_, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("ok");
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (!addr || typeof addr === "string") {
throw new Error("unexpected address");
}
process.stdout.write(`${addr.port}\n`);
});
const keepAlive = setInterval(() => {}, 1000);
const shutdown = () => {
server.close(() => process.exit(0));
clearInterval(keepAlive);
process.exit(0);
};
process.on("SIGTERM", shutdown);