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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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