mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
perf(test): speed up suites and reduce fs churn
This commit is contained in:
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
150
src/infra/openclaw-root.test.ts
Normal file
150
src/infra/openclaw-root.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
18
test/fixtures/child-process-bridge/child.js
vendored
18
test/fixtures/child-process-bridge/child.js
vendored
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user