refactor(core): dedupe shared config and runtime helpers

This commit is contained in:
Peter Steinberger
2026-02-16 14:52:03 +00:00
parent 544ffbcf7b
commit 04892ee230
68 changed files with 1966 additions and 2018 deletions

View File

@@ -14,6 +14,29 @@ const { createService, shutdown, registerUnhandledRejectionHandler, logWarn, log
const asString = (value: unknown, fallback: string) =>
typeof value === "string" && value.trim() ? value : fallback;
function mockCiaoService(params?: {
advertise?: ReturnType<typeof vi.fn>;
destroy?: ReturnType<typeof vi.fn>;
serviceState?: string;
on?: ReturnType<typeof vi.fn>;
}) {
const advertise = params?.advertise ?? vi.fn().mockResolvedValue(undefined);
const destroy = params?.destroy ?? vi.fn().mockResolvedValue(undefined);
const on = params?.on ?? vi.fn();
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: params?.serviceState ?? "announced",
on,
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
return { advertise, destroy, on };
}
vi.mock("../logger.js", async () => {
const actual = await vi.importActual<typeof import("../logger.js")>("../logger.js");
return {
@@ -96,18 +119,7 @@ describe("gateway bonjour advertiser", () => {
setTimeout(resolve, 250);
}),
);
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: "announced",
on: vi.fn(),
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
mockCiaoService({ advertise, destroy });
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
@@ -149,18 +161,7 @@ describe("gateway bonjour advertiser", () => {
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: "announced",
on: vi.fn(),
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
mockCiaoService({ advertise, destroy });
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
@@ -188,20 +189,10 @@ describe("gateway bonjour advertiser", () => {
const advertise = vi.fn().mockResolvedValue(undefined);
const onCalls: Array<{ event: string }> = [];
createService.mockImplementation((options: Record<string, unknown>) => {
const on = vi.fn((event: string) => {
onCalls.push({ event });
});
return {
advertise,
destroy,
serviceState: "announced",
on,
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
const on = vi.fn((event: string) => {
onCalls.push({ event });
});
mockCiaoService({ advertise, destroy, on });
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
@@ -228,18 +219,7 @@ describe("gateway bonjour advertiser", () => {
shutdown.mockImplementation(async () => {
order.push("shutdown");
});
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: "announced",
on: vi.fn(),
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
mockCiaoService({ advertise, destroy });
const cleanup = vi.fn(() => {
order.push("cleanup");
@@ -272,18 +252,7 @@ describe("gateway bonjour advertiser", () => {
.fn()
.mockRejectedValueOnce(new Error("boom")) // initial advertise fails
.mockResolvedValue(undefined); // watchdog retry succeeds
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: "unannounced",
on: vi.fn(),
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
mockCiaoService({ advertise, destroy, serviceState: "unannounced" });
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
@@ -319,18 +288,7 @@ describe("gateway bonjour advertiser", () => {
const advertise = vi.fn(() => {
throw new Error("sync-fail");
});
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: "unannounced",
on: vi.fn(),
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
mockCiaoService({ advertise, destroy, serviceState: "unannounced" });
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
@@ -352,17 +310,7 @@ describe("gateway bonjour advertiser", () => {
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: "announced",
on: vi.fn(),
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
mockCiaoService({ advertise, destroy });
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,

View File

@@ -28,6 +28,7 @@ vi.mock("node:fs", async (importOriginal) => {
const resolved = absInMock(p);
return resolved === fixturesRoot.slice(0, -1) || resolved.startsWith(fixturesRoot);
};
const readFixtureEntry = (p: string) => state.entries.get(absInMock(p));
const wrapped = {
...actual,
@@ -38,25 +39,25 @@ vi.mock("node:fs", async (importOriginal) => {
// 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}'`);
const entry = readFixtureEntry(p);
if (entry?.kind === "file") {
return entry.content;
}
return entry.content;
throw new Error(`ENOENT: no such file, open '${p}'`);
},
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}'`);
const entry = readFixtureEntry(p);
if (entry?.kind === "file") {
return { isFile: () => true, isDirectory: () => false };
}
return {
isFile: () => entry.kind === "file",
isDirectory: () => entry.kind === "dir",
};
if (entry?.kind === "dir") {
return { isFile: () => false, isDirectory: () => true };
}
throw new Error(`ENOENT: no such file or directory, stat '${p}'`);
},
realpathSync: (p: string) =>
isFixturePath(p)

View File

@@ -1,3 +1,5 @@
import { pruneMapToMaxSize } from "./map-size.js";
export type DedupeCache = {
check: (key: string | undefined | null, now?: number) => boolean;
clear: () => void;
@@ -32,13 +34,7 @@ export function createDedupeCache(options: DedupeCacheOptions): DedupeCache {
cache.clear();
return;
}
while (cache.size > maxSize) {
const oldestKey = cache.keys().next().value;
if (!oldestKey) {
break;
}
cache.delete(oldestKey);
}
pruneMapToMaxSize(cache, maxSize);
};
return {

View File

@@ -9,29 +9,12 @@ async function writeEnvFile(filePath: string, contents: string) {
await fs.writeFile(filePath, contents, "utf8");
}
describe("loadDotEnv", () => {
it("loads ~/.openclaw/.env as fallback without overriding CWD .env", async () => {
const prevEnv = { ...process.env };
const prevCwd = process.cwd();
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-"));
const cwdDir = path.join(base, "cwd");
const stateDir = path.join(base, "state");
process.env.OPENCLAW_STATE_DIR = stateDir;
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\nBAR=1\n");
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
process.chdir(cwdDir);
delete process.env.FOO;
delete process.env.BAR;
loadDotEnv({ quiet: true });
expect(process.env.FOO).toBe("from-cwd");
expect(process.env.BAR).toBe("1");
async function withIsolatedEnvAndCwd(run: () => Promise<void>) {
const prevEnv = { ...process.env };
const prevCwd = process.cwd();
try {
await run();
} finally {
process.chdir(prevCwd);
for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) {
@@ -45,40 +28,49 @@ describe("loadDotEnv", () => {
process.env[key] = value;
}
}
}
}
describe("loadDotEnv", () => {
it("loads ~/.openclaw/.env as fallback without overriding CWD .env", async () => {
await withIsolatedEnvAndCwd(async () => {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-"));
const cwdDir = path.join(base, "cwd");
const stateDir = path.join(base, "state");
process.env.OPENCLAW_STATE_DIR = stateDir;
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\nBAR=1\n");
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
process.chdir(cwdDir);
delete process.env.FOO;
delete process.env.BAR;
loadDotEnv({ quiet: true });
expect(process.env.FOO).toBe("from-cwd");
expect(process.env.BAR).toBe("1");
});
});
it("does not override an already-set env var from the shell", async () => {
const prevEnv = { ...process.env };
const prevCwd = process.cwd();
await withIsolatedEnvAndCwd(async () => {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-"));
const cwdDir = path.join(base, "cwd");
const stateDir = path.join(base, "state");
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-test-"));
const cwdDir = path.join(base, "cwd");
const stateDir = path.join(base, "state");
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.FOO = "from-shell";
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.FOO = "from-shell";
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
process.chdir(cwdDir);
process.chdir(cwdDir);
loadDotEnv({ quiet: true });
loadDotEnv({ quiet: true });
expect(process.env.FOO).toBe("from-shell");
process.chdir(prevCwd);
for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) {
delete process.env[key];
}
}
for (const [key, value] of Object.entries(prevEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
expect(process.env.FOO).toBe("from-shell");
});
});
});

View File

@@ -24,18 +24,40 @@ function getFirstDeliveryText(deliver: ReturnType<typeof vi.fn>): string {
return firstCall?.payloads?.[0]?.text ?? "";
}
const TARGETS_CFG = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
} as OpenClawConfig;
function createForwarder(params: {
cfg: OpenClawConfig;
deliver?: ReturnType<typeof vi.fn>;
resolveSessionTarget?: () => { channel: string; to: string } | null;
}) {
const deliver = params.deliver ?? vi.fn().mockResolvedValue([]);
const forwarder = createExecApprovalForwarder({
getConfig: () => params.cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: params.resolveSessionTarget ?? (() => null),
});
return { deliver, forwarder };
}
describe("exec approval forwarder", () => {
it("forwards to session target and resolves", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: { exec: { enabled: true, mode: "session" } },
} as OpenClawConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "slack", to: "U1" }),
});
@@ -56,23 +78,7 @@ describe("exec approval forwarder", () => {
it("forwards to explicit targets and expires", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
} as OpenClawConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => null,
});
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
await forwarder.handleRequested(baseRequest);
expect(deliver).toHaveBeenCalledTimes(1);
@@ -83,23 +89,7 @@ describe("exec approval forwarder", () => {
it("formats single-line commands as inline code", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
} as OpenClawConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => null,
});
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
await forwarder.handleRequested(baseRequest);
@@ -108,23 +98,7 @@ describe("exec approval forwarder", () => {
it("formats complex commands as fenced code blocks", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
} as OpenClawConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => null,
});
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
await forwarder.handleRequested({
...baseRequest,
@@ -139,7 +113,6 @@ describe("exec approval forwarder", () => {
it("skips discord forwarding when discord exec approvals target channel", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: { exec: { enabled: true, mode: "session" } },
channels: {
@@ -153,10 +126,8 @@ describe("exec approval forwarder", () => {
},
} as OpenClawConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }),
});
@@ -167,23 +138,7 @@ describe("exec approval forwarder", () => {
it("uses a longer fence when command already contains triple backticks", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
} as OpenClawConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => null,
});
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
await forwarder.handleRequested({
...baseRequest,

View File

@@ -34,6 +34,26 @@ function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-"));
}
function createSafeBinJqCase(params: { command: string; seedFileName?: string }) {
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
const exe = path.join(binDir, exeName);
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
if (params.seedFileName) {
fs.writeFileSync(path.join(dir, params.seedFileName), "{}");
}
const res = analyzeShellCommand({
command: params.command,
cwd: dir,
env: makePathEnv(binDir),
});
expect(res.ok).toBe(true);
return { dir, segment: res.segments[0] };
}
describe("exec approvals allowlist matching", () => {
it("ignores basename-only patterns", () => {
const resolution = {
@@ -389,20 +409,7 @@ describe("exec approvals safe bins", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
const exe = path.join(binDir, exeName);
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
const res = analyzeShellCommand({
command: "jq .foo",
cwd: dir,
env: makePathEnv(binDir),
});
expect(res.ok).toBe(true);
const segment = res.segments[0];
const { dir, segment } = createSafeBinJqCase({ command: "jq .foo" });
const ok = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
@@ -416,22 +423,10 @@ describe("exec approvals safe bins", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
const exe = path.join(binDir, exeName);
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
const file = path.join(dir, "secret.json");
fs.writeFileSync(file, "{}");
const res = analyzeShellCommand({
const { dir, segment } = createSafeBinJqCase({
command: "jq .foo secret.json",
cwd: dir,
env: makePathEnv(binDir),
seedFileName: "secret.json",
});
expect(res.ok).toBe(true);
const segment = res.segments[0];
const ok = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,

View File

@@ -62,6 +62,25 @@ function makeProcStat(pid: number, startTime: number) {
return `${pid} (node) ${fields.join(" ")}`;
}
function createLockPayload(params: { configPath: string; startTime: number; createdAt?: string }) {
return {
pid: process.pid,
createdAt: params.createdAt ?? new Date().toISOString(),
configPath: params.configPath,
startTime: params.startTime,
};
}
function mockProcStatRead(params: { onProcRead: () => string }) {
const readFileSync = fsSync.readFileSync;
return vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${process.pid}/stat`) {
return params.onProcRead();
}
return readFileSync(filePath as never, encoding as never) as never;
});
}
describe("gateway lock", () => {
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-"));
@@ -119,21 +138,12 @@ describe("gateway lock", () => {
vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z"));
const { env, cleanup } = await makeEnv();
const { lockPath, configPath } = resolveLockPath(env);
const payload = {
pid: process.pid,
createdAt: new Date().toISOString(),
configPath,
startTime: 111,
};
const payload = createLockPayload({ configPath, startTime: 111 });
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
const readFileSync = fsSync.readFileSync;
const statValue = makeProcStat(process.pid, 222);
const spy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${process.pid}/stat`) {
return statValue;
}
return readFileSync(filePath as never, encoding as never) as never;
const spy = mockProcStatRead({
onProcRead: () => statValue,
});
const lock = await acquireGatewayLock({
@@ -154,20 +164,13 @@ describe("gateway lock", () => {
vi.useRealTimers();
const { env, cleanup } = await makeEnv();
const { lockPath, configPath } = resolveLockPath(env);
const payload = {
pid: process.pid,
createdAt: new Date().toISOString(),
configPath,
startTime: 111,
};
const payload = createLockPayload({ configPath, startTime: 111 });
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
const readFileSync = fsSync.readFileSync;
const spy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${process.pid}/stat`) {
const spy = mockProcStatRead({
onProcRead: () => {
throw new Error("EACCES");
}
return readFileSync(filePath as never, encoding as never) as never;
},
});
const pending = acquireGatewayLock({
@@ -182,17 +185,17 @@ describe("gateway lock", () => {
spy.mockRestore();
const stalePayload = {
...payload,
const stalePayload = createLockPayload({
configPath,
startTime: 111,
createdAt: new Date(0).toISOString(),
};
});
await fs.writeFile(lockPath, JSON.stringify(stalePayload), "utf8");
const staleSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${process.pid}/stat`) {
const staleSpy = mockProcStatRead({
onProcRead: () => {
throw new Error("EACCES");
}
return readFileSync(filePath as never, encoding as never) as never;
},
});
const lock = await acquireGatewayLock({

View File

@@ -86,6 +86,40 @@ describe("Ghost reminder bug (issue #13317)", () => {
expect(calledCtx?.Body).not.toContain("heartbeat poll");
};
const runCronReminderCase = async (
tmpPrefix: string,
enqueue: (sessionKey: string) => void,
): Promise<{
result: Awaited<ReturnType<typeof runHeartbeatOnce>>;
sendTelegram: ReturnType<typeof vi.fn>;
getReplySpy: ReturnType<typeof vi.spyOn<typeof replyModule, "getReplyFromConfig">>;
}> => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tmpPrefix));
const sendTelegram = vi.fn().mockResolvedValue({
messageId: "m1",
chatId: "155462274",
});
const getReplySpy = vi
.spyOn(replyModule, "getReplyFromConfig")
.mockResolvedValue({ text: "Relay this reminder now" });
try {
const { cfg, sessionKey } = await createConfig(tmpDir);
enqueue(sessionKey);
const result = await runHeartbeatOnce({
cfg,
agentId: "main",
reason: "cron:reminder-job",
deps: {
sendTelegram,
},
});
return { result, sendTelegram, getReplySpy };
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
};
it("does not use CRON_EVENT_PROMPT when only a HEARTBEAT_OK event is present", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ghost-"));
const sendTelegram = vi.fn().mockResolvedValue({
@@ -122,68 +156,28 @@ describe("Ghost reminder bug (issue #13317)", () => {
});
it("uses CRON_EVENT_PROMPT when an actionable cron event exists", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-"));
const sendTelegram = vi.fn().mockResolvedValue({
messageId: "m1",
chatId: "155462274",
});
const getReplySpy = vi
.spyOn(replyModule, "getReplyFromConfig")
.mockResolvedValue({ text: "Relay this reminder now" });
try {
const { cfg } = await createConfig(tmpDir);
enqueueSystemEvent("Reminder: Check Base Scout results", {
sessionKey: resolveMainSessionKey(cfg),
});
const result = await runHeartbeatOnce({
cfg,
agentId: "main",
reason: "cron:reminder-job",
deps: {
sendTelegram,
},
});
expect(result.status).toBe("ran");
expectCronEventPrompt(getReplySpy, "Reminder: Check Base Scout results");
expect(sendTelegram).toHaveBeenCalled();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
const { result, sendTelegram, getReplySpy } = await runCronReminderCase(
"openclaw-cron-",
(sessionKey) => {
enqueueSystemEvent("Reminder: Check Base Scout results", { sessionKey });
},
);
expect(result.status).toBe("ran");
expectCronEventPrompt(getReplySpy, "Reminder: Check Base Scout results");
expect(sendTelegram).toHaveBeenCalled();
});
it("uses CRON_EVENT_PROMPT when cron events are mixed with heartbeat noise", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-mixed-"));
const sendTelegram = vi.fn().mockResolvedValue({
messageId: "m1",
chatId: "155462274",
});
const getReplySpy = vi
.spyOn(replyModule, "getReplyFromConfig")
.mockResolvedValue({ text: "Relay this reminder now" });
try {
const { cfg, sessionKey } = await createConfig(tmpDir);
enqueueSystemEvent("HEARTBEAT_OK", { sessionKey });
enqueueSystemEvent("Reminder: Check Base Scout results", { sessionKey });
const result = await runHeartbeatOnce({
cfg,
agentId: "main",
reason: "cron:reminder-job",
deps: {
sendTelegram,
},
});
expect(result.status).toBe("ran");
expectCronEventPrompt(getReplySpy, "Reminder: Check Base Scout results");
expect(sendTelegram).toHaveBeenCalled();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
const { result, sendTelegram, getReplySpy } = await runCronReminderCase(
"openclaw-cron-mixed-",
(sessionKey) => {
enqueueSystemEvent("HEARTBEAT_OK", { sessionKey });
enqueueSystemEvent("Reminder: Check Base Scout results", { sessionKey });
},
);
expect(result.status).toBe("ran");
expectCronEventPrompt(getReplySpy, "Reminder: Check Base Scout results");
expect(sendTelegram).toHaveBeenCalled();
});
it("uses CRON_EVENT_PROMPT for tagged cron events on interval wake", async () => {

View File

@@ -1,33 +1,17 @@
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 { 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 * as replyModule from "../auto-reply/reply.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime } from "../plugins/runtime/index.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { runHeartbeatOnce } from "./heartbeat-runner.js";
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
// Avoid pulling optional runtime deps during isolated runs.
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
beforeEach(() => {
const runtime = createPluginRuntime();
setTelegramRuntime(runtime);
setWhatsAppRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
]),
);
});
installHeartbeatRunnerTestRuntime();
describe("resolveHeartbeatIntervalMs", () => {
async function seedSessionStore(
@@ -82,21 +66,16 @@ describe("resolveHeartbeatIntervalMs", () => {
replySpy: ReturnType<typeof vi.spyOn>;
}) => Promise<T>,
) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
return await fn({ tmpDir, storePath, replySpy });
return await withTempHeartbeatSandbox(fn);
} finally {
replySpy.mockRestore();
if (prevTelegramToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
}

View File

@@ -3,6 +3,15 @@ import type { OpenClawConfig } from "../config/config.js";
import { startHeartbeatRunner } from "./heartbeat-runner.js";
describe("startHeartbeatRunner", () => {
function startDefaultRunner(runOnce: (typeof startHeartbeatRunner)[0]["runOnce"]) {
return startHeartbeatRunner({
cfg: {
agents: { defaults: { heartbeat: { every: "30m" } } },
} as OpenClawConfig,
runOnce,
});
}
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
@@ -14,12 +23,7 @@ describe("startHeartbeatRunner", () => {
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
const runner = startHeartbeatRunner({
cfg: {
agents: { defaults: { heartbeat: { every: "30m" } } },
} as OpenClawConfig,
runOnce: runSpy,
});
const runner = startDefaultRunner(runSpy);
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
@@ -69,12 +73,7 @@ describe("startHeartbeatRunner", () => {
return { status: "ran", durationMs: 1 };
});
const runner = startHeartbeatRunner({
cfg: {
agents: { defaults: { heartbeat: { every: "30m" } } },
} as OpenClawConfig,
runOnce: runSpy,
});
const runner = startDefaultRunner(runSpy);
// First heartbeat fires and throws
await vi.advanceTimersByTimeAsync(30 * 60_000 + 1_000);
@@ -124,12 +123,7 @@ describe("startHeartbeatRunner", () => {
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
const runner = startHeartbeatRunner({
cfg: {
agents: { defaults: { heartbeat: { every: "30m" } } },
} as OpenClawConfig,
runOnce: runSpy,
});
const runner = startDefaultRunner(runSpy);
runner.stop();

View File

@@ -1,37 +1,17 @@
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 { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { slackPlugin } from "../../extensions/slack/src/channel.js";
import { setSlackRuntime } from "../../extensions/slack/src/runtime.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 * as replyModule from "../auto-reply/reply.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime } from "../plugins/runtime/index.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { runHeartbeatOnce } from "./heartbeat-runner.js";
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
// Avoid pulling optional runtime deps during isolated runs.
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
beforeEach(() => {
const runtime = createPluginRuntime();
setSlackRuntime(runtime);
setTelegramRuntime(runtime);
setWhatsAppRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "slack", plugin: slackPlugin, source: "test" },
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
]),
);
});
installHeartbeatRunnerTestRuntime({ includeSlack: true });
describe("runHeartbeatOnce", () => {
it("uses the delivery target as sender when lastTo differs", async () => {

View File

@@ -0,0 +1,40 @@
import { beforeEach } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import { slackPlugin } from "../../extensions/slack/src/channel.js";
import { setSlackRuntime } from "../../extensions/slack/src/runtime.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 { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime } from "../plugins/runtime/index.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
const slackChannelPlugin = slackPlugin as unknown as ChannelPlugin;
const telegramChannelPlugin = telegramPlugin as unknown as ChannelPlugin;
const whatsappChannelPlugin = whatsappPlugin as unknown as ChannelPlugin;
export function installHeartbeatRunnerTestRuntime(params?: { includeSlack?: boolean }): void {
beforeEach(() => {
const runtime = createPluginRuntime();
setTelegramRuntime(runtime);
setWhatsAppRuntime(runtime);
if (params?.includeSlack) {
setSlackRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "slack", plugin: slackChannelPlugin, source: "test" },
{ pluginId: "whatsapp", plugin: whatsappChannelPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramChannelPlugin, source: "test" },
]),
);
return;
}
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "whatsapp", plugin: whatsappChannelPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramChannelPlugin, source: "test" },
]),
);
});
}

View File

@@ -3,6 +3,25 @@ import type { OpenClawConfig } from "../config/config.js";
import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
describe("resolveHeartbeatVisibility", () => {
function createTelegramAccountHeartbeatConfig(): OpenClawConfig {
return {
channels: {
telegram: {
heartbeat: {
showOk: true,
},
accounts: {
primary: {
heartbeat: {
showOk: false,
},
},
},
},
},
} as OpenClawConfig;
}
it("returns default values when no config is provided", () => {
const cfg = {} as OpenClawConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
@@ -136,46 +155,14 @@ describe("resolveHeartbeatVisibility", () => {
});
it("handles missing accountId gracefully", () => {
const cfg = {
channels: {
telegram: {
heartbeat: {
showOk: true,
},
accounts: {
primary: {
heartbeat: {
showOk: false,
},
},
},
},
},
} as OpenClawConfig;
const cfg = createTelegramAccountHeartbeatConfig();
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
expect(result.showOk).toBe(true);
});
it("handles non-existent account gracefully", () => {
const cfg = {
channels: {
telegram: {
heartbeat: {
showOk: true,
},
accounts: {
primary: {
heartbeat: {
showOk: false,
},
},
},
},
},
} as OpenClawConfig;
const cfg = createTelegramAccountHeartbeatConfig();
const result = resolveHeartbeatVisibility({
cfg,
channel: "telegram",

View File

@@ -8,6 +8,25 @@ import {
} from "./heartbeat-wake.js";
describe("heartbeat-wake", () => {
async function expectRetryAfterDefaultDelay(params: {
handler: ReturnType<typeof vi.fn>;
initialReason: string;
expectedRetryReason: string;
}) {
setHeartbeatWakeHandler(params.handler);
requestHeartbeatNow({ reason: params.initialReason, coalesceMs: 0 });
await vi.advanceTimersByTimeAsync(1);
expect(params.handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
expect(params.handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
expect(params.handler).toHaveBeenCalledTimes(2);
expect(params.handler.mock.calls[1]?.[0]).toEqual({ reason: params.expectedRetryReason });
}
beforeEach(() => {
resetHeartbeatWakeStateForTests();
});
@@ -44,19 +63,11 @@ describe("heartbeat-wake", () => {
.fn()
.mockResolvedValueOnce({ status: "skipped", reason: "requests-in-flight" })
.mockResolvedValueOnce({ status: "ran", durationMs: 1 });
setHeartbeatWakeHandler(handler);
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
await vi.advanceTimersByTimeAsync(1);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
expect(handler).toHaveBeenCalledTimes(2);
expect(handler.mock.calls[1]?.[0]).toEqual({ reason: "interval" });
await expectRetryAfterDefaultDelay({
handler,
initialReason: "interval",
expectedRetryReason: "interval",
});
});
it("keeps retry cooldown even when a sooner request arrives", async () => {
@@ -87,19 +98,11 @@ describe("heartbeat-wake", () => {
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce({ status: "skipped", reason: "disabled" });
setHeartbeatWakeHandler(handler);
requestHeartbeatNow({ reason: "exec-event", coalesceMs: 0 });
await vi.advanceTimersByTimeAsync(1);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
expect(handler).toHaveBeenCalledTimes(2);
expect(handler.mock.calls[1]?.[0]).toEqual({ reason: "exec-event" });
await expectRetryAfterDefaultDelay({
handler,
initialReason: "exec-event",
expectedRetryReason: "exec-event",
});
});
it("stale disposer does not clear a newer handler", async () => {

View File

@@ -1,6 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { IncomingMessage } from "node:http";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import { createMockServerResponse } from "../test-utils/mock-http-response.js";
import {
installRequestBodyLimitGuard,
isRequestBodyLimitError,
@@ -52,24 +53,6 @@ function createMockRequest(params: {
return req;
}
function createMockResponse(): ServerResponse & { body?: string } {
const headers: Record<string, string> = {};
const res = {
headersSent: false,
statusCode: 200,
setHeader: (key: string, value: string) => {
headers[key.toLowerCase()] = value;
return res;
},
end: (body?: string) => {
res.headersSent = true;
res.body = body;
return res;
},
} as unknown as ServerResponse & { body?: string };
return res;
}
describe("http body limits", () => {
it("reads body within max bytes", async () => {
const req = createMockRequest({ chunks: ['{"ok":true}'] });
@@ -104,7 +87,7 @@ describe("http body limits", () => {
headers: { "content-length": "9999" },
emitEnd: false,
});
const res = createMockResponse();
const res = createMockServerResponse();
const guard = installRequestBodyLimitGuard(req, res, { maxBytes: 128 });
expect(guard.isTripped()).toBe(true);
expect(guard.code()).toBe("PAYLOAD_TOO_LARGE");
@@ -113,7 +96,7 @@ describe("http body limits", () => {
it("guard rejects streamed oversized body", async () => {
const req = createMockRequest({ chunks: ["small", "x".repeat(256)], emitEnd: false });
const res = createMockResponse();
const res = createMockServerResponse();
const guard = installRequestBodyLimitGuard(req, res, { maxBytes: 128, responseFormat: "text" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(guard.isTripped()).toBe(true);

View File

@@ -18,6 +18,21 @@ import { getShellPathFromLoginShell, resetShellPathCacheForTests } from "./shell
import { listTailnetAddresses } from "./tailnet.js";
describe("infra runtime", () => {
function setupRestartSignalSuite() {
beforeEach(() => {
__testing.resetSigusr1State();
vi.useFakeTimers();
vi.spyOn(process, "kill").mockImplementation(() => true);
});
afterEach(async () => {
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
vi.restoreAllMocks();
__testing.resetSigusr1State();
});
}
describe("ensureBinary", () => {
it("passes through when binary exists", async () => {
const exec: typeof runExec = vi.fn().mockResolvedValue({
@@ -69,18 +84,7 @@ describe("infra runtime", () => {
});
describe("restart authorization", () => {
beforeEach(() => {
__testing.resetSigusr1State();
vi.useFakeTimers();
vi.spyOn(process, "kill").mockImplementation(() => true);
});
afterEach(async () => {
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
vi.restoreAllMocks();
__testing.resetSigusr1State();
});
setupRestartSignalSuite();
it("authorizes exactly once when scheduled restart emits", async () => {
expect(consumeGatewaySigusr1RestartAuthorization()).toBe(false);
@@ -124,18 +128,7 @@ describe("infra runtime", () => {
});
describe("pre-restart deferral check", () => {
beforeEach(() => {
__testing.resetSigusr1State();
vi.useFakeTimers();
vi.spyOn(process, "kill").mockImplementation(() => true);
});
afterEach(async () => {
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
vi.restoreAllMocks();
__testing.resetSigusr1State();
});
setupRestartSignalSuite();
it("emits SIGUSR1 immediately when no deferral check is registered", async () => {
const emitSpy = vi.spyOn(process, "emit");

15
src/infra/map-size.ts Normal file
View File

@@ -0,0 +1,15 @@
export function pruneMapToMaxSize<K, V>(map: Map<K, V>, maxSize: number): void {
const limit = Math.max(0, Math.floor(maxSize));
if (limit <= 0) {
map.clear();
return;
}
while (map.size > limit) {
const oldest = map.keys().next();
if (oldest.done) {
break;
}
map.delete(oldest.value);
}
}

View File

@@ -14,6 +14,11 @@ const state = vi.hoisted(() => ({
const abs = (p: string) => path.resolve(p);
const fx = (...parts: string[]) => path.join(FIXTURE_BASE, ...parts);
const vitestRootWithSep = `${abs(VITEST_FS_BASE)}${path.sep}`;
const isFixturePath = (p: string) => {
const resolved = abs(p);
return resolved === vitestRootWithSep.slice(0, -1) || resolved.startsWith(vitestRootWithSep);
};
function setFile(p: string, content = "") {
state.entries.set(abs(p), { kind: "file", content });
@@ -21,23 +26,16 @@ function setFile(p: string, 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),
isFixturePath(p) ? state.entries.has(abs(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));
const entry = state.entries.get(abs(p));
if (!entry || entry.kind !== "file") {
throw new Error(`ENOENT: no such file, open '${p}'`);
}
@@ -48,7 +46,7 @@ vi.mock("node:fs", async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return actual.statSync(p as any) as unknown;
}
const entry = state.entries.get(absInMock(p));
const entry = state.entries.get(abs(p));
if (!entry) {
throw new Error(`ENOENT: no such file or directory, stat '${p}'`);
}
@@ -58,22 +56,13 @@ vi.mock("node:fs", async (importOriginal) => {
};
},
realpathSync: (p: string) =>
isFixturePath(p)
? (state.realpaths.get(absInMock(p)) ?? absInMock(p))
: actual.realpathSync(p),
isFixturePath(p) ? (state.realpaths.get(abs(p)) ?? abs(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) => {
@@ -81,7 +70,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
// 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));
const entry = state.entries.get(abs(p));
if (!entry || entry.kind !== "file") {
throw new Error(`ENOENT: no such file, open '${p}'`);
}

View File

@@ -89,7 +89,7 @@ describe("sendMessage replyToId threading", () => {
setRegistry(emptyRegistry);
});
it("passes replyToId through to the outbound adapter", async () => {
const setupMattermostCapture = () => {
const capturedCtx: Record<string, unknown>[] = [];
const plugin = createMattermostLikePlugin({
onSendText: (ctx) => {
@@ -97,6 +97,11 @@ describe("sendMessage replyToId threading", () => {
},
});
setRegistry(createTestRegistry([{ pluginId: "mattermost", source: "test", plugin }]));
return capturedCtx;
};
it("passes replyToId through to the outbound adapter", async () => {
const capturedCtx = setupMattermostCapture();
await sendMessage({
cfg: {},
@@ -111,13 +116,7 @@ describe("sendMessage replyToId threading", () => {
});
it("passes threadId through to the outbound adapter", async () => {
const capturedCtx: Record<string, unknown>[] = [];
const plugin = createMattermostLikePlugin({
onSendText: (ctx) => {
capturedCtx.push(ctx);
},
});
setRegistry(createTestRegistry([{ pluginId: "mattermost", source: "test", plugin }]));
const capturedCtx = setupMattermostCapture();
await sendMessage({
cfg: {},

View File

@@ -7,12 +7,22 @@ const makeResponse = (status: number, body: unknown): Response => {
return new Response(payload, { status, headers });
};
const toRequestUrl = (input: Parameters<typeof fetch>[0]): string =>
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const createAntigravityFetch = (
handler: (url: string, init?: Parameters<typeof fetch>[1]) => Promise<Response> | Response,
) =>
vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input, init) =>
handler(toRequestUrl(input), init),
);
const getRequestBody = (init?: Parameters<typeof fetch>[1]) =>
typeof init?.body === "string" ? init.body : undefined;
describe("fetchAntigravityUsage", () => {
it("returns 3 windows when both endpoints succeed", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 750,
@@ -69,10 +79,7 @@ describe("fetchAntigravityUsage", () => {
});
it("returns Credits only when loadCodeAssist succeeds but fetchAvailableModels fails", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 250,
@@ -103,10 +110,7 @@ describe("fetchAntigravityUsage", () => {
});
it("returns model IDs when fetchAvailableModels succeeds but loadCodeAssist fails", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(500, "Internal server error");
}
@@ -144,27 +148,22 @@ describe("fetchAntigravityUsage", () => {
it("uses cloudaicompanionProject string as project id", async () => {
let capturedBody: string | undefined;
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url, init) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 900,
planInfo: { monthlyPromptCredits: 1000 },
cloudaicompanionProject: "projects/alpha",
});
}
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 900,
planInfo: { monthlyPromptCredits: 1000 },
cloudaicompanionProject: "projects/alpha",
});
}
if (url.includes("fetchAvailableModels")) {
capturedBody = getRequestBody(init);
return makeResponse(200, { models: {} });
}
if (url.includes("fetchAvailableModels")) {
capturedBody = init?.body?.toString();
return makeResponse(200, { models: {} });
}
return makeResponse(404, "not found");
},
);
return makeResponse(404, "not found");
});
await fetchAntigravityUsage("token-123", 5000, mockFetch);
@@ -173,27 +172,22 @@ describe("fetchAntigravityUsage", () => {
it("uses cloudaicompanionProject object id when present", async () => {
let capturedBody: string | undefined;
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url, init) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 900,
planInfo: { monthlyPromptCredits: 1000 },
cloudaicompanionProject: { id: "projects/beta" },
});
}
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 900,
planInfo: { monthlyPromptCredits: 1000 },
cloudaicompanionProject: { id: "projects/beta" },
});
}
if (url.includes("fetchAvailableModels")) {
capturedBody = getRequestBody(init);
return makeResponse(200, { models: {} });
}
if (url.includes("fetchAvailableModels")) {
capturedBody = init?.body?.toString();
return makeResponse(200, { models: {} });
}
return makeResponse(404, "not found");
},
);
return makeResponse(404, "not found");
});
await fetchAntigravityUsage("token-123", 5000, mockFetch);
@@ -201,10 +195,7 @@ describe("fetchAntigravityUsage", () => {
});
it("returns error snapshot when both endpoints fail", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(403, { error: { message: "Access denied" } });
}
@@ -226,10 +217,7 @@ describe("fetchAntigravityUsage", () => {
});
it("returns Token expired when fetchAvailableModels returns 401 and no windows", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(500, "Boom");
}
@@ -248,10 +236,7 @@ describe("fetchAntigravityUsage", () => {
});
it("extracts plan info from currentTier.name", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 500,
@@ -274,10 +259,7 @@ describe("fetchAntigravityUsage", () => {
});
it("falls back to planType when currentTier.name is missing", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 500,
@@ -300,10 +282,7 @@ describe("fetchAntigravityUsage", () => {
it("includes reset times in model windows", async () => {
const resetTime = "2026-01-10T12:00:00Z";
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(500, "Error");
}
@@ -328,10 +307,7 @@ describe("fetchAntigravityUsage", () => {
});
it("parses string numbers correctly", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: "600",
@@ -364,10 +340,7 @@ describe("fetchAntigravityUsage", () => {
});
it("skips internal models", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 500,
@@ -395,10 +368,7 @@ describe("fetchAntigravityUsage", () => {
});
it("sorts models by usage and shows individual model IDs", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(500, "Error");
}
@@ -440,10 +410,7 @@ describe("fetchAntigravityUsage", () => {
});
it("returns Token expired error on 401 from loadCodeAssist", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(401, { error: { message: "Unauthorized" } });
}
@@ -459,10 +426,7 @@ describe("fetchAntigravityUsage", () => {
});
it("handles empty models array gracefully", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(200, {
availablePromptCredits: 800,
@@ -486,10 +450,7 @@ describe("fetchAntigravityUsage", () => {
});
it("handles missing credits fields gracefully", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(200, { planType: "Free" });
}
@@ -517,10 +478,7 @@ describe("fetchAntigravityUsage", () => {
});
it("handles invalid reset time gracefully", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
return makeResponse(500, "Error");
}
@@ -546,10 +504,7 @@ describe("fetchAntigravityUsage", () => {
});
it("handles network errors with graceful degradation", async () => {
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const mockFetch = createAntigravityFetch(async (url) => {
if (url.includes("loadCodeAssist")) {
throw new Error("Network failure");
}

View File

@@ -10,6 +10,48 @@ import {
type UsageSummary,
} from "./provider-usage.js";
const minimaxRemainsEndpoint = "api.minimaxi.com/v1/api/openplatform/coding_plan/remains";
function makeResponse(status: number, body: unknown): Response {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
}
function toRequestUrl(input: Parameters<typeof fetch>[0]): string {
return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
}
function createMinimaxOnlyFetch(payload: unknown) {
return vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
if (toRequestUrl(input).includes(minimaxRemainsEndpoint)) {
return makeResponse(200, payload);
}
return makeResponse(404, "not found");
});
}
async function expectMinimaxUsage(
payload: unknown,
expectedUsedPercent: number,
expectedPlan?: string,
) {
const mockFetch = createMinimaxOnlyFetch(payload);
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
auth: [{ provider: "minimax", token: "token-1b" }],
fetch: mockFetch,
});
const minimax = summary.providers.find((p) => p.provider === "minimax");
expect(minimax?.windows[0]?.usedPercent).toBe(expectedUsedPercent);
if (expectedPlan !== undefined) {
expect(minimax?.plan).toBe(expectedPlan);
}
expect(mockFetch).toHaveBeenCalled();
}
describe("provider usage formatting", () => {
it("returns null when no usage is available", () => {
const summary: UsageSummary = { updatedAt: 0, providers: [] };
@@ -71,15 +113,8 @@ describe("provider usage formatting", () => {
describe("provider usage loading", () => {
it("loads usage snapshots with injected auth", async () => {
const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const url = toRequestUrl(input);
if (url.includes("api.anthropic.com")) {
return makeResponse(200, {
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
@@ -103,7 +138,7 @@ describe("provider usage loading", () => {
},
});
}
if (url.includes("api.minimaxi.com/v1/api/openplatform/coding_plan/remains")) {
if (url.includes(minimaxRemainsEndpoint)) {
return makeResponse(200, {
base_resp: { status_code: 0, status_msg: "ok" },
data: {
@@ -138,115 +173,55 @@ describe("provider usage loading", () => {
});
it("handles nested MiniMax usage payloads", async () => {
const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.minimaxi.com/v1/api/openplatform/coding_plan/remains")) {
return makeResponse(200, {
base_resp: { status_code: 0, status_msg: "ok" },
data: {
plan_name: "Coding Plan",
usage: {
prompt_limit: 200,
prompt_remain: 50,
next_reset_time: "2026-01-07T05:00:00Z",
},
await expectMinimaxUsage(
{
base_resp: { status_code: 0, status_msg: "ok" },
data: {
plan_name: "Coding Plan",
usage: {
prompt_limit: 200,
prompt_remain: 50,
next_reset_time: "2026-01-07T05:00:00Z",
},
});
}
return makeResponse(404, "not found");
});
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
auth: [{ provider: "minimax", token: "token-1b" }],
fetch: mockFetch,
});
const minimax = summary.providers.find((p) => p.provider === "minimax");
expect(minimax?.windows[0]?.usedPercent).toBe(75);
expect(minimax?.plan).toBe("Coding Plan");
expect(mockFetch).toHaveBeenCalled();
},
},
75,
"Coding Plan",
);
});
it("prefers MiniMax count-based usage when percent looks inverted", async () => {
const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.minimaxi.com/v1/api/openplatform/coding_plan/remains")) {
return makeResponse(200, {
base_resp: { status_code: 0, status_msg: "ok" },
data: {
prompt_limit: 200,
prompt_remain: 150,
usage_percent: 75,
next_reset_time: "2026-01-07T05:00:00Z",
},
});
}
return makeResponse(404, "not found");
});
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
auth: [{ provider: "minimax", token: "token-1b" }],
fetch: mockFetch,
});
const minimax = summary.providers.find((p) => p.provider === "minimax");
expect(minimax?.windows[0]?.usedPercent).toBe(25);
expect(mockFetch).toHaveBeenCalled();
await expectMinimaxUsage(
{
base_resp: { status_code: 0, status_msg: "ok" },
data: {
prompt_limit: 200,
prompt_remain: 150,
usage_percent: 75,
next_reset_time: "2026-01-07T05:00:00Z",
},
},
25,
);
});
it("handles MiniMax model_remains usage payloads", async () => {
const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.minimaxi.com/v1/api/openplatform/coding_plan/remains")) {
return makeResponse(200, {
base_resp: { status_code: 0, status_msg: "ok" },
model_remains: [
{
start_time: 1736217600,
end_time: 1736235600,
remains_time: 600,
current_interval_total_count: 120,
current_interval_usage_count: 30,
model_name: "MiniMax-M2.1",
},
],
});
}
return makeResponse(404, "not found");
});
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
auth: [{ provider: "minimax", token: "token-1b" }],
fetch: mockFetch,
});
const minimax = summary.providers.find((p) => p.provider === "minimax");
expect(minimax?.windows[0]?.usedPercent).toBe(25);
expect(mockFetch).toHaveBeenCalled();
await expectMinimaxUsage(
{
base_resp: { status_code: 0, status_msg: "ok" },
model_remains: [
{
start_time: 1736217600,
end_time: 1736235600,
remains_time: 600,
current_interval_total_count: 120,
current_interval_usage_count: 30,
model_name: "MiniMax-M2.1",
},
],
},
25,
);
});
it("discovers Claude usage from token auth profiles", async () => {

View File

@@ -0,0 +1,28 @@
type RuntimeStatusFormatInput = {
status?: string;
pid?: number;
state?: string;
details?: string[];
};
export function formatRuntimeStatusWithDetails({
status,
pid,
state,
details = [],
}: RuntimeStatusFormatInput): string {
const runtimeStatus = status ?? "unknown";
const fullDetails: string[] = [];
if (pid) {
fullDetails.push(`pid ${pid}`);
}
if (state && state.toLowerCase() !== runtimeStatus) {
fullDetails.push(`state ${state}`);
}
for (const detail of details) {
if (detail) {
fullDetails.push(detail);
}
}
return fullDetails.length > 0 ? `${runtimeStatus} (${fullDetails.join(", ")})` : runtimeStatus;
}

View File

@@ -2,20 +2,25 @@ import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
type MockSpawnChild = EventEmitter & {
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
kill?: (signal?: string) => void;
};
function createMockSpawnChild() {
const child = new EventEmitter() as MockSpawnChild;
const stdout = new EventEmitter() as MockSpawnChild["stdout"];
stdout!.setEncoding = vi.fn();
child.stdout = stdout;
child.kill = vi.fn();
return { child, stdout };
}
vi.mock("node:child_process", () => {
const spawn = vi.fn(() => {
const child = new EventEmitter() as EventEmitter & {
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
kill?: (signal?: string) => void;
};
const stdout = new EventEmitter() as EventEmitter & {
setEncoding?: (enc: string) => void;
};
stdout.setEncoding = vi.fn();
child.stdout = stdout;
child.kill = vi.fn();
const { child, stdout } = createMockSpawnChild();
process.nextTick(() => {
stdout.emit(
stdout?.emit(
"data",
[
"user steipete",
@@ -60,16 +65,7 @@ describe("ssh-config", () => {
it("returns null when ssh -G fails", async () => {
spawnMock.mockImplementationOnce(() => {
const child = new EventEmitter() as EventEmitter & {
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
kill?: (signal?: string) => void;
};
const stdout = new EventEmitter() as EventEmitter & {
setEncoding?: (enc: string) => void;
};
stdout.setEncoding = vi.fn();
child.stdout = stdout;
child.kill = vi.fn();
const { child } = createMockSpawnChild();
process.nextTick(() => {
child.emit("exit", 1);
});

View File

@@ -2,26 +2,39 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { POSIX_OPENCLAW_TMP_DIR, resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
function fallbackTmp(uid = 501) {
return path.join("/var/fallback", `openclaw-${uid}`);
}
function resolveWithMocks(params: {
lstatSync: ReturnType<typeof vi.fn>;
accessSync?: ReturnType<typeof vi.fn>;
uid?: number;
tmpdirPath?: string;
}) {
const accessSync = params.accessSync ?? vi.fn();
const mkdirSync = vi.fn();
const getuid = vi.fn(() => params.uid ?? 501);
const tmpdir = vi.fn(() => params.tmpdirPath ?? "/var/fallback");
const resolved = resolvePreferredOpenClawTmpDir({
accessSync,
lstatSync: params.lstatSync,
mkdirSync,
getuid,
tmpdir,
});
return { resolved, accessSync, lstatSync: params.lstatSync, mkdirSync, tmpdir };
}
describe("resolvePreferredOpenClawTmpDir", () => {
it("prefers /tmp/openclaw when it already exists and is writable", () => {
const accessSync = vi.fn();
const lstatSync = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => false,
uid: 501,
mode: 0o40700,
}));
const mkdirSync = vi.fn();
const getuid = vi.fn(() => 501);
const tmpdir = vi.fn(() => "/var/fallback");
const resolved = resolvePreferredOpenClawTmpDir({
accessSync,
lstatSync,
mkdirSync,
getuid,
tmpdir,
});
const { resolved, accessSync, tmpdir } = resolveWithMocks({ lstatSync });
expect(lstatSync).toHaveBeenCalledTimes(1);
expect(accessSync).toHaveBeenCalledTimes(1);
@@ -30,15 +43,11 @@ describe("resolvePreferredOpenClawTmpDir", () => {
});
it("prefers /tmp/openclaw when it does not exist but /tmp is writable", () => {
const accessSync = vi.fn();
const lstatSync = vi.fn(() => {
const err = new Error("missing") as Error & { code?: string };
err.code = "ENOENT";
throw err;
});
const mkdirSync = vi.fn();
const getuid = vi.fn(() => 501);
const tmpdir = vi.fn(() => "/var/fallback");
// second lstat call (after mkdir) should succeed
lstatSync.mockImplementationOnce(() => {
@@ -53,13 +62,7 @@ describe("resolvePreferredOpenClawTmpDir", () => {
mode: 0o40700,
}));
const resolved = resolvePreferredOpenClawTmpDir({
accessSync,
lstatSync,
mkdirSync,
getuid,
tmpdir,
});
const { resolved, accessSync, mkdirSync, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR);
expect(accessSync).toHaveBeenCalledWith("/tmp", expect.any(Number));
@@ -68,26 +71,15 @@ describe("resolvePreferredOpenClawTmpDir", () => {
});
it("falls back to os.tmpdir()/openclaw when /tmp/openclaw is not a directory", () => {
const accessSync = vi.fn();
const lstatSync = vi.fn(() => ({
isDirectory: () => false,
isSymbolicLink: () => false,
uid: 501,
mode: 0o100644,
}));
const mkdirSync = vi.fn();
const getuid = vi.fn(() => 501);
const tmpdir = vi.fn(() => "/var/fallback");
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
const resolved = resolvePreferredOpenClawTmpDir({
accessSync,
lstatSync,
mkdirSync,
getuid,
tmpdir,
});
expect(resolved).toBe(path.join("/var/fallback", "openclaw-501"));
expect(resolved).toBe(fallbackTmp());
expect(tmpdir).toHaveBeenCalledTimes(1);
});
@@ -102,91 +94,53 @@ describe("resolvePreferredOpenClawTmpDir", () => {
err.code = "ENOENT";
throw err;
});
const mkdirSync = vi.fn();
const getuid = vi.fn(() => 501);
const tmpdir = vi.fn(() => "/var/fallback");
const resolved = resolvePreferredOpenClawTmpDir({
const { resolved, tmpdir } = resolveWithMocks({
accessSync,
lstatSync,
mkdirSync,
getuid,
tmpdir,
});
expect(resolved).toBe(path.join("/var/fallback", "openclaw-501"));
expect(resolved).toBe(fallbackTmp());
expect(tmpdir).toHaveBeenCalledTimes(1);
});
it("falls back when /tmp/openclaw is a symlink", () => {
const accessSync = vi.fn();
const lstatSync = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => true,
uid: 501,
mode: 0o120777,
}));
const mkdirSync = vi.fn();
const getuid = vi.fn(() => 501);
const tmpdir = vi.fn(() => "/var/fallback");
const resolved = resolvePreferredOpenClawTmpDir({
accessSync,
lstatSync,
mkdirSync,
getuid,
tmpdir,
});
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(path.join("/var/fallback", "openclaw-501"));
expect(resolved).toBe(fallbackTmp());
expect(tmpdir).toHaveBeenCalledTimes(1);
});
it("falls back when /tmp/openclaw is not owned by the current user", () => {
const accessSync = vi.fn();
const lstatSync = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => false,
uid: 0,
mode: 0o40700,
}));
const mkdirSync = vi.fn();
const getuid = vi.fn(() => 501);
const tmpdir = vi.fn(() => "/var/fallback");
const resolved = resolvePreferredOpenClawTmpDir({
accessSync,
lstatSync,
mkdirSync,
getuid,
tmpdir,
});
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(path.join("/var/fallback", "openclaw-501"));
expect(resolved).toBe(fallbackTmp());
expect(tmpdir).toHaveBeenCalledTimes(1);
});
it("falls back when /tmp/openclaw is group/other writable", () => {
const accessSync = vi.fn();
const lstatSync = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => false,
uid: 501,
mode: 0o40777,
}));
const mkdirSync = vi.fn();
const getuid = vi.fn(() => 501);
const tmpdir = vi.fn(() => "/var/fallback");
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
const resolved = resolvePreferredOpenClawTmpDir({
accessSync,
lstatSync,
mkdirSync,
getuid,
tmpdir,
});
expect(resolved).toBe(path.join("/var/fallback", "openclaw-501"));
expect(resolved).toBe(fallbackTmp());
expect(tmpdir).toHaveBeenCalledTimes(1);
});
});

View File

@@ -105,13 +105,28 @@ describe("runGatewayUpdate", () => {
};
}
it("skips git update when worktree is dirty", async () => {
async function setupGitCheckout(options?: { packageManager?: string }) {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.0" }),
"utf-8",
);
const pkg: Record<string, string> = { name: "openclaw", version: "1.0.0" };
if (options?.packageManager) {
pkg.packageManager = options.packageManager;
}
await fs.writeFile(path.join(tempDir, "package.json"), JSON.stringify(pkg), "utf-8");
}
async function setupUiIndex() {
const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html");
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
await fs.writeFile(uiIndexPath, "<html></html>", "utf-8");
return uiIndexPath;
}
async function removeControlUiAssets() {
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
}
it("skips git update when worktree is dirty", async () => {
await setupGitCheckout();
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
@@ -131,12 +146,7 @@ describe("runGatewayUpdate", () => {
});
it("aborts rebase on failure", async () => {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.0" }),
"utf-8",
);
await setupGitCheckout();
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
@@ -164,15 +174,8 @@ describe("runGatewayUpdate", () => {
});
it("uses stable tag when beta tag is older than release", async () => {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
"utf-8",
);
const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html");
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
await fs.writeFile(uiIndexPath, "<html></html>", "utf-8");
await setupGitCheckout({ packageManager: "pnpm@8.0.0" });
await setupUiIndex();
const stableTag = "v1.0.1-1";
const betaTag = "v1.0.0-beta.2";
const { runner, calls } = createRunner({
@@ -243,29 +246,18 @@ describe("runGatewayUpdate", () => {
"utf-8",
);
const calls: string[] = [];
const runCommand = async (argv: string[]) => {
const key = argv.join(" ");
calls.push(key);
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
return { stdout: "", stderr: "not a git repository", code: 128 };
}
if (key === "npm root -g") {
return { stdout: nodeModules, stderr: "", code: 0 };
}
if (key === params.expectedInstallCommand) {
const { calls, runCommand } = createGlobalInstallHarness({
pkgRoot,
npmRootOutput: nodeModules,
installCommand: params.expectedInstallCommand,
onInstall: async () => {
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
"utf-8",
);
return { stdout: "ok", stderr: "", code: 0 };
}
if (key === "pnpm root -g") {
return { stdout: "", stderr: "", code: 1 };
}
return { stdout: "", stderr: "", code: 0 };
};
},
});
const result = await runGatewayUpdate({
cwd: pkgRoot,
@@ -278,6 +270,37 @@ describe("runGatewayUpdate", () => {
return { calls, result };
}
const createGlobalInstallHarness = (params: {
pkgRoot: string;
npmRootOutput?: string;
installCommand: string;
onInstall?: () => Promise<void>;
}) => {
const calls: string[] = [];
const runCommand = async (argv: string[]) => {
const key = argv.join(" ");
calls.push(key);
if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) {
return { stdout: "", stderr: "not a git repository", code: 128 };
}
if (key === "npm root -g") {
if (params.npmRootOutput) {
return { stdout: params.npmRootOutput, stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 1 };
}
if (key === "pnpm root -g") {
return { stdout: "", stderr: "", code: 1 };
}
if (key === params.installCommand) {
await params.onInstall?.();
return { stdout: "ok", stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
};
return { calls, runCommand };
};
it.each([
{
title: "updates global npm installs when detected",
@@ -364,29 +387,17 @@ describe("runGatewayUpdate", () => {
"utf-8",
);
const calls: string[] = [];
const runCommand = async (argv: string[]) => {
const key = argv.join(" ");
calls.push(key);
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
return { stdout: "", stderr: "not a git repository", code: 128 };
}
if (key === "npm root -g") {
return { stdout: "", stderr: "", code: 1 };
}
if (key === "pnpm root -g") {
return { stdout: "", stderr: "", code: 1 };
}
if (key === "bun add -g openclaw@latest") {
const { calls, runCommand } = createGlobalInstallHarness({
pkgRoot,
installCommand: "bun add -g openclaw@latest",
onInstall: async () => {
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
"utf-8",
);
return { stdout: "ok", stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
};
},
});
const result = await runGatewayUpdate({
cwd: pkgRoot,
@@ -429,12 +440,7 @@ describe("runGatewayUpdate", () => {
});
it("fails with a clear reason when openclaw.mjs is missing", async () => {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
"utf-8",
);
await setupGitCheckout({ packageManager: "pnpm@8.0.0" });
await fs.rm(path.join(tempDir, "openclaw.mjs"), { force: true });
const stableTag = "v1.0.1-1";
@@ -463,15 +469,8 @@ describe("runGatewayUpdate", () => {
});
it("repairs UI assets when doctor run removes control-ui files", async () => {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
"utf-8",
);
const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html");
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
await fs.writeFile(uiIndexPath, "<html></html>", "utf-8");
await setupGitCheckout({ packageManager: "pnpm@8.0.0" });
const uiIndexPath = await setupUiIndex();
const stableTag = "v1.0.1-1";
const { runCommand, calls, doctorKey, getUiBuildCount } = createStableTagRunner({
@@ -481,9 +480,7 @@ describe("runGatewayUpdate", () => {
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
await fs.writeFile(uiIndexPath, `<html>${count}</html>`, "utf-8");
},
onDoctor: async () => {
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
},
onDoctor: removeControlUiAssets,
});
const result = await runGatewayUpdate({
@@ -500,15 +497,8 @@ describe("runGatewayUpdate", () => {
});
it("fails when UI assets are still missing after post-doctor repair", async () => {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
"utf-8",
);
const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html");
await fs.mkdir(path.dirname(uiIndexPath), { recursive: true });
await fs.writeFile(uiIndexPath, "<html></html>", "utf-8");
await setupGitCheckout({ packageManager: "pnpm@8.0.0" });
const uiIndexPath = await setupUiIndex();
const stableTag = "v1.0.1-1";
const { runCommand } = createStableTagRunner({
@@ -520,9 +510,7 @@ describe("runGatewayUpdate", () => {
await fs.writeFile(uiIndexPath, "<html>built</html>", "utf-8");
}
},
onDoctor: async () => {
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
},
onDoctor: removeControlUiAssets,
});
const result = await runGatewayUpdate({

View File

@@ -109,7 +109,7 @@ describe("update-startup", () => {
suiteCase = 0;
});
it("logs update hint for npm installs when newer tag exists", async () => {
async function runUpdateCheckAndReadState(channel: "stable" | "beta") {
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
vi.mocked(checkUpdateStatus).mockResolvedValue({
root: "/opt/openclaw",
@@ -123,49 +123,35 @@ describe("update-startup", () => {
const log = { info: vi.fn() };
await runGatewayUpdateCheck({
cfg: { update: { channel: "stable" } },
cfg: { update: { channel } },
log,
isNixMode: false,
allowInTests: true,
});
const statePath = path.join(tempDir, "update-check.json");
const parsed = JSON.parse(await fs.readFile(statePath, "utf-8")) as {
lastNotifiedVersion?: string;
lastNotifiedTag?: string;
};
return { log, parsed };
}
it("logs update hint for npm installs when newer tag exists", async () => {
const { log, parsed } = await runUpdateCheckAndReadState("stable");
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining("update available (latest): v2.0.0"),
);
const statePath = path.join(tempDir, "update-check.json");
const raw = await fs.readFile(statePath, "utf-8");
const parsed = JSON.parse(raw) as { lastNotifiedVersion?: string };
expect(parsed.lastNotifiedVersion).toBe("2.0.0");
});
it("uses latest when beta tag is older than release", async () => {
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue("/opt/openclaw");
vi.mocked(checkUpdateStatus).mockResolvedValue({
root: "/opt/openclaw",
installKind: "package",
packageManager: "npm",
} satisfies UpdateCheckResult);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2.0.0",
});
const log = { info: vi.fn() };
await runGatewayUpdateCheck({
cfg: { update: { channel: "beta" } },
log,
isNixMode: false,
allowInTests: true,
});
const { log, parsed } = await runUpdateCheckAndReadState("beta");
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining("update available (latest): v2.0.0"),
);
const statePath = path.join(tempDir, "update-check.json");
const raw = await fs.readFile(statePath, "utf-8");
const parsed = JSON.parse(raw) as { lastNotifiedTag?: string };
expect(parsed.lastNotifiedTag).toBe("latest");
});