mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 21:01:43 +03:00
refactor(test): consolidate infra unit tests
This commit is contained in:
@@ -1,33 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import type { runExec } from "../process/exec.js";
|
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
|
||||||
import { ensureBinary } from "./binaries.js";
|
|
||||||
|
|
||||||
describe("ensureBinary", () => {
|
|
||||||
it("passes through when binary exists", async () => {
|
|
||||||
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
|
||||||
stdout: "",
|
|
||||||
stderr: "",
|
|
||||||
});
|
|
||||||
const runtime: RuntimeEnv = {
|
|
||||||
log: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
exit: vi.fn(),
|
|
||||||
};
|
|
||||||
await ensureBinary("node", exec, runtime);
|
|
||||||
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs and exits when missing", async () => {
|
|
||||||
const exec: typeof runExec = vi.fn().mockRejectedValue(new Error("missing"));
|
|
||||||
const error = vi.fn();
|
|
||||||
const exit = vi.fn(() => {
|
|
||||||
throw new Error("exit");
|
|
||||||
});
|
|
||||||
await expect(ensureBinary("ghost", exec, { log: vi.fn(), error, exit })).rejects.toThrow(
|
|
||||||
"exit",
|
|
||||||
);
|
|
||||||
expect(error).toHaveBeenCalledWith("Missing required binary: ghost. Please install it.");
|
|
||||||
expect(exit).toHaveBeenCalledWith(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
getChannelActivity,
|
|
||||||
recordChannelActivity,
|
|
||||||
resetChannelActivityForTest,
|
|
||||||
} from "./channel-activity.js";
|
|
||||||
|
|
||||||
describe("channel activity", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
resetChannelActivityForTest();
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date("2026-01-08T00:00:00Z"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("records inbound/outbound separately", () => {
|
|
||||||
recordChannelActivity({ channel: "telegram", direction: "inbound" });
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
recordChannelActivity({ channel: "telegram", direction: "outbound" });
|
|
||||||
const res = getChannelActivity({ channel: "telegram" });
|
|
||||||
expect(res.inboundAt).toBe(1767830400000);
|
|
||||||
expect(res.outboundAt).toBe(1767830401000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isolates accounts", () => {
|
|
||||||
recordChannelActivity({
|
|
||||||
channel: "whatsapp",
|
|
||||||
accountId: "a",
|
|
||||||
direction: "inbound",
|
|
||||||
at: 1,
|
|
||||||
});
|
|
||||||
recordChannelActivity({
|
|
||||||
channel: "whatsapp",
|
|
||||||
accountId: "b",
|
|
||||||
direction: "inbound",
|
|
||||||
at: 2,
|
|
||||||
});
|
|
||||||
expect(getChannelActivity({ channel: "whatsapp", accountId: "a" })).toEqual({
|
|
||||||
inboundAt: 1,
|
|
||||||
outboundAt: null,
|
|
||||||
});
|
|
||||||
expect(getChannelActivity({ channel: "whatsapp", accountId: "b" })).toEqual({
|
|
||||||
inboundAt: 2,
|
|
||||||
outboundAt: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { createDedupeCache } from "./dedupe.js";
|
|
||||||
|
|
||||||
describe("createDedupeCache", () => {
|
|
||||||
it("marks duplicates within TTL", () => {
|
|
||||||
const cache = createDedupeCache({ ttlMs: 1000, maxSize: 10 });
|
|
||||||
expect(cache.check("a", 100)).toBe(false);
|
|
||||||
expect(cache.check("a", 500)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expires entries after TTL", () => {
|
|
||||||
const cache = createDedupeCache({ ttlMs: 1000, maxSize: 10 });
|
|
||||||
expect(cache.check("a", 100)).toBe(false);
|
|
||||||
expect(cache.check("a", 1501)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("evicts oldest entries when over max size", () => {
|
|
||||||
const cache = createDedupeCache({ ttlMs: 10_000, maxSize: 2 });
|
|
||||||
expect(cache.check("a", 100)).toBe(false);
|
|
||||||
expect(cache.check("b", 200)).toBe(false);
|
|
||||||
expect(cache.check("c", 300)).toBe(false);
|
|
||||||
expect(cache.check("a", 400)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prunes expired entries even when refreshed keys are older in insertion order", () => {
|
|
||||||
const cache = createDedupeCache({ ttlMs: 100, maxSize: 10 });
|
|
||||||
expect(cache.check("a", 0)).toBe(false);
|
|
||||||
expect(cache.check("b", 50)).toBe(false);
|
|
||||||
expect(cache.check("a", 120)).toBe(false);
|
|
||||||
expect(cache.check("c", 200)).toBe(false);
|
|
||||||
expect(cache.size()).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import {
|
|
||||||
emitDiagnosticEvent,
|
|
||||||
onDiagnosticEvent,
|
|
||||||
resetDiagnosticEventsForTest,
|
|
||||||
} from "./diagnostic-events.js";
|
|
||||||
|
|
||||||
describe("diagnostic-events", () => {
|
|
||||||
test("emits monotonic seq", async () => {
|
|
||||||
resetDiagnosticEventsForTest();
|
|
||||||
const seqs: number[] = [];
|
|
||||||
const stop = onDiagnosticEvent((evt) => seqs.push(evt.seq));
|
|
||||||
|
|
||||||
emitDiagnosticEvent({
|
|
||||||
type: "model.usage",
|
|
||||||
usage: { total: 1 },
|
|
||||||
});
|
|
||||||
emitDiagnosticEvent({
|
|
||||||
type: "model.usage",
|
|
||||||
usage: { total: 2 },
|
|
||||||
});
|
|
||||||
|
|
||||||
stop();
|
|
||||||
|
|
||||||
expect(seqs).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("emits message-flow events", async () => {
|
|
||||||
resetDiagnosticEventsForTest();
|
|
||||||
const types: string[] = [];
|
|
||||||
const stop = onDiagnosticEvent((evt) => types.push(evt.type));
|
|
||||||
|
|
||||||
emitDiagnosticEvent({
|
|
||||||
type: "webhook.received",
|
|
||||||
channel: "telegram",
|
|
||||||
updateType: "telegram-post",
|
|
||||||
});
|
|
||||||
emitDiagnosticEvent({
|
|
||||||
type: "message.queued",
|
|
||||||
channel: "telegram",
|
|
||||||
source: "telegram",
|
|
||||||
queueDepth: 1,
|
|
||||||
});
|
|
||||||
emitDiagnosticEvent({
|
|
||||||
type: "session.state",
|
|
||||||
state: "processing",
|
|
||||||
reason: "run_started",
|
|
||||||
});
|
|
||||||
|
|
||||||
stop();
|
|
||||||
|
|
||||||
expect(types).toEqual(["webhook.received", "message.queued", "session.state"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
|
||||||
import { isDiagnosticFlagEnabled, resolveDiagnosticFlags } from "./diagnostic-flags.js";
|
|
||||||
|
|
||||||
describe("diagnostic flags", () => {
|
|
||||||
it("merges config + env flags", () => {
|
|
||||||
const cfg = {
|
|
||||||
diagnostics: { flags: ["telegram.http", "cache.*"] },
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const env = {
|
|
||||||
OPENCLAW_DIAGNOSTICS: "foo,bar",
|
|
||||||
} as NodeJS.ProcessEnv;
|
|
||||||
|
|
||||||
const flags = resolveDiagnosticFlags(cfg, env);
|
|
||||||
expect(flags).toEqual(expect.arrayContaining(["telegram.http", "cache.*", "foo", "bar"]));
|
|
||||||
expect(isDiagnosticFlagEnabled("telegram.http", cfg, env)).toBe(true);
|
|
||||||
expect(isDiagnosticFlagEnabled("cache.hit", cfg, env)).toBe(true);
|
|
||||||
expect(isDiagnosticFlagEnabled("foo", cfg, env)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats env true as wildcard", () => {
|
|
||||||
const env = { OPENCLAW_DIAGNOSTICS: "1" } as NodeJS.ProcessEnv;
|
|
||||||
expect(isDiagnosticFlagEnabled("anything.here", undefined, env)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats env false as disabled", () => {
|
|
||||||
const env = { OPENCLAW_DIAGNOSTICS: "0" } as NodeJS.ProcessEnv;
|
|
||||||
expect(isDiagnosticFlagEnabled("telegram.http", undefined, env)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import { isDiagnosticFlagEnabled, resolveDiagnosticFlags } from "./diagnostic-flags.js";
|
||||||
|
import { isMainModule } from "./is-main.js";
|
||||||
|
import { buildNodeShellCommand } from "./node-shell.js";
|
||||||
|
import { parseSshTarget } from "./ssh-tunnel.js";
|
||||||
|
|
||||||
|
describe("infra parsing", () => {
|
||||||
|
describe("diagnostic flags", () => {
|
||||||
|
it("merges config + env flags", () => {
|
||||||
|
const cfg = {
|
||||||
|
diagnostics: { flags: ["telegram.http", "cache.*"] },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const env = {
|
||||||
|
OPENCLAW_DIAGNOSTICS: "foo,bar",
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
const flags = resolveDiagnosticFlags(cfg, env);
|
||||||
|
expect(flags).toEqual(expect.arrayContaining(["telegram.http", "cache.*", "foo", "bar"]));
|
||||||
|
expect(isDiagnosticFlagEnabled("telegram.http", cfg, env)).toBe(true);
|
||||||
|
expect(isDiagnosticFlagEnabled("cache.hit", cfg, env)).toBe(true);
|
||||||
|
expect(isDiagnosticFlagEnabled("foo", cfg, env)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats env true as wildcard", () => {
|
||||||
|
const env = { OPENCLAW_DIAGNOSTICS: "1" } as NodeJS.ProcessEnv;
|
||||||
|
expect(isDiagnosticFlagEnabled("anything.here", undefined, env)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats env false as disabled", () => {
|
||||||
|
const env = { OPENCLAW_DIAGNOSTICS: "0" } as NodeJS.ProcessEnv;
|
||||||
|
expect(isDiagnosticFlagEnabled("telegram.http", undefined, env)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isMainModule", () => {
|
||||||
|
it("returns true when argv[1] matches current file", () => {
|
||||||
|
expect(
|
||||||
|
isMainModule({
|
||||||
|
currentFile: "/repo/dist/index.js",
|
||||||
|
argv: ["node", "/repo/dist/index.js"],
|
||||||
|
cwd: "/repo",
|
||||||
|
env: {},
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true under PM2 when pm_exec_path matches current file", () => {
|
||||||
|
expect(
|
||||||
|
isMainModule({
|
||||||
|
currentFile: "/repo/dist/index.js",
|
||||||
|
argv: ["node", "/pm2/lib/ProcessContainerFork.js"],
|
||||||
|
cwd: "/repo",
|
||||||
|
env: { pm_exec_path: "/repo/dist/index.js", pm_id: "0" },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when running under PM2 but this module is imported", () => {
|
||||||
|
expect(
|
||||||
|
isMainModule({
|
||||||
|
currentFile: "/repo/node_modules/openclaw/dist/index.js",
|
||||||
|
argv: ["node", "/repo/app.js"],
|
||||||
|
cwd: "/repo",
|
||||||
|
env: { pm_exec_path: "/repo/app.js", pm_id: "0" },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildNodeShellCommand", () => {
|
||||||
|
it("uses cmd.exe for win32", () => {
|
||||||
|
expect(buildNodeShellCommand("echo hi", "win32")).toEqual([
|
||||||
|
"cmd.exe",
|
||||||
|
"/d",
|
||||||
|
"/s",
|
||||||
|
"/c",
|
||||||
|
"echo hi",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses cmd.exe for windows labels", () => {
|
||||||
|
expect(buildNodeShellCommand("echo hi", "windows")).toEqual([
|
||||||
|
"cmd.exe",
|
||||||
|
"/d",
|
||||||
|
"/s",
|
||||||
|
"/c",
|
||||||
|
"echo hi",
|
||||||
|
]);
|
||||||
|
expect(buildNodeShellCommand("echo hi", "Windows 11")).toEqual([
|
||||||
|
"cmd.exe",
|
||||||
|
"/d",
|
||||||
|
"/s",
|
||||||
|
"/c",
|
||||||
|
"echo hi",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses /bin/sh for darwin", () => {
|
||||||
|
expect(buildNodeShellCommand("echo hi", "darwin")).toEqual(["/bin/sh", "-lc", "echo hi"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses /bin/sh when platform missing", () => {
|
||||||
|
expect(buildNodeShellCommand("echo hi")).toEqual(["/bin/sh", "-lc", "echo hi"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseSshTarget", () => {
|
||||||
|
it("parses user@host:port targets", () => {
|
||||||
|
expect(parseSshTarget("me@example.com:2222")).toEqual({
|
||||||
|
user: "me",
|
||||||
|
host: "example.com",
|
||||||
|
port: 2222,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses host-only targets with default port", () => {
|
||||||
|
expect(parseSshTarget("example.com")).toEqual({
|
||||||
|
user: undefined,
|
||||||
|
host: "example.com",
|
||||||
|
port: 22,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects hostnames that start with '-'", () => {
|
||||||
|
expect(parseSshTarget("-V")).toBeNull();
|
||||||
|
expect(parseSshTarget("me@-badhost")).toBeNull();
|
||||||
|
expect(parseSshTarget("-oProxyCommand=echo")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { runExec } from "../process/exec.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { ensureBinary } from "./binaries.js";
|
||||||
|
import {
|
||||||
|
__testing,
|
||||||
|
consumeGatewaySigusr1RestartAuthorization,
|
||||||
|
isGatewaySigusr1RestartExternallyAllowed,
|
||||||
|
scheduleGatewaySigusr1Restart,
|
||||||
|
setGatewaySigusr1RestartPolicy,
|
||||||
|
} from "./restart.js";
|
||||||
|
import { createTelegramRetryRunner } from "./retry-policy.js";
|
||||||
|
import { getShellPathFromLoginShell, resetShellPathCacheForTests } from "./shell-env.js";
|
||||||
|
import { listTailnetAddresses } from "./tailnet.js";
|
||||||
|
|
||||||
|
describe("infra runtime", () => {
|
||||||
|
describe("ensureBinary", () => {
|
||||||
|
it("passes through when binary exists", async () => {
|
||||||
|
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
const runtime: RuntimeEnv = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
await ensureBinary("node", exec, runtime);
|
||||||
|
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and exits when missing", async () => {
|
||||||
|
const exec: typeof runExec = vi.fn().mockRejectedValue(new Error("missing"));
|
||||||
|
const error = vi.fn();
|
||||||
|
const exit = vi.fn(() => {
|
||||||
|
throw new Error("exit");
|
||||||
|
});
|
||||||
|
await expect(ensureBinary("ghost", exec, { log: vi.fn(), error, exit })).rejects.toThrow(
|
||||||
|
"exit",
|
||||||
|
);
|
||||||
|
expect(error).toHaveBeenCalledWith("Missing required binary: ghost. Please install it.");
|
||||||
|
expect(exit).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTelegramRetryRunner", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries when custom shouldRetry matches non-telegram error", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const runner = createTelegramRetryRunner({
|
||||||
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||||
|
shouldRetry: (err) => err instanceof Error && err.message === "boom",
|
||||||
|
});
|
||||||
|
const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValue("ok");
|
||||||
|
|
||||||
|
const promise = runner(fn, "request");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
await expect(promise).resolves.toBe("ok");
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes a scheduled authorization once", async () => {
|
||||||
|
expect(consumeGatewaySigusr1RestartAuthorization()).toBe(false);
|
||||||
|
|
||||||
|
scheduleGatewaySigusr1Restart({ delayMs: 0 });
|
||||||
|
|
||||||
|
expect(consumeGatewaySigusr1RestartAuthorization()).toBe(true);
|
||||||
|
expect(consumeGatewaySigusr1RestartAuthorization()).toBe(false);
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks external restart policy", () => {
|
||||||
|
expect(isGatewaySigusr1RestartExternallyAllowed()).toBe(false);
|
||||||
|
setGatewaySigusr1RestartPolicy({ allowExternal: true });
|
||||||
|
expect(isGatewaySigusr1RestartExternallyAllowed()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getShellPathFromLoginShell", () => {
|
||||||
|
afterEach(() => resetShellPathCacheForTests());
|
||||||
|
|
||||||
|
it("returns PATH from login shell env", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exec = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(Buffer.from("PATH=/custom/bin\0HOME=/home/user\0", "utf-8"));
|
||||||
|
const result = getShellPathFromLoginShell({ env: { SHELL: "/bin/sh" }, exec });
|
||||||
|
expect(result).toBe("/custom/bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches the value", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exec = vi.fn().mockReturnValue(Buffer.from("PATH=/custom/bin\0", "utf-8"));
|
||||||
|
const env = { SHELL: "/bin/sh" } as NodeJS.ProcessEnv;
|
||||||
|
expect(getShellPathFromLoginShell({ env, exec })).toBe("/custom/bin");
|
||||||
|
expect(getShellPathFromLoginShell({ env, exec })).toBe("/custom/bin");
|
||||||
|
expect(exec).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on exec failure", () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exec = vi.fn(() => {
|
||||||
|
throw new Error("boom");
|
||||||
|
});
|
||||||
|
const result = getShellPathFromLoginShell({ env: { SHELL: "/bin/sh" }, exec });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tailnet address detection", () => {
|
||||||
|
it("detects tailscale IPv4 and IPv6 addresses", () => {
|
||||||
|
vi.spyOn(os, "networkInterfaces").mockReturnValue({
|
||||||
|
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
|
||||||
|
utun9: [
|
||||||
|
{
|
||||||
|
address: "100.123.224.76",
|
||||||
|
family: "IPv4",
|
||||||
|
internal: false,
|
||||||
|
netmask: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
address: "fd7a:115c:a1e0::8801:e04c",
|
||||||
|
family: "IPv6",
|
||||||
|
internal: false,
|
||||||
|
netmask: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const out = listTailnetAddresses();
|
||||||
|
expect(out.ipv4).toEqual(["100.123.224.76"]);
|
||||||
|
expect(out.ipv6).toEqual(["fd7a:115c:a1e0::8801:e04c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
getChannelActivity,
|
||||||
|
recordChannelActivity,
|
||||||
|
resetChannelActivityForTest,
|
||||||
|
} from "./channel-activity.js";
|
||||||
|
import { createDedupeCache } from "./dedupe.js";
|
||||||
|
import {
|
||||||
|
emitDiagnosticEvent,
|
||||||
|
onDiagnosticEvent,
|
||||||
|
resetDiagnosticEventsForTest,
|
||||||
|
} from "./diagnostic-events.js";
|
||||||
|
import { readSessionStoreJson5 } from "./state-migrations.fs.js";
|
||||||
|
import {
|
||||||
|
defaultVoiceWakeTriggers,
|
||||||
|
loadVoiceWakeConfig,
|
||||||
|
setVoiceWakeTriggers,
|
||||||
|
} from "./voicewake.js";
|
||||||
|
|
||||||
|
describe("infra store", () => {
|
||||||
|
describe("state migrations fs", () => {
|
||||||
|
it("treats array session stores as invalid", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-"));
|
||||||
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
await fs.writeFile(storePath, "[]", "utf-8");
|
||||||
|
|
||||||
|
const result = readSessionStoreJson5(storePath);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.store).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("voicewake store", () => {
|
||||||
|
it("returns defaults when missing", async () => {
|
||||||
|
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voicewake-"));
|
||||||
|
const cfg = await loadVoiceWakeConfig(baseDir);
|
||||||
|
expect(cfg.triggers).toEqual(defaultVoiceWakeTriggers());
|
||||||
|
expect(cfg.updatedAtMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes and persists triggers", async () => {
|
||||||
|
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voicewake-"));
|
||||||
|
const saved = await setVoiceWakeTriggers([" hi ", "", " there "], baseDir);
|
||||||
|
expect(saved.triggers).toEqual(["hi", "there"]);
|
||||||
|
expect(saved.updatedAtMs).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const loaded = await loadVoiceWakeConfig(baseDir);
|
||||||
|
expect(loaded.triggers).toEqual(["hi", "there"]);
|
||||||
|
expect(loaded.updatedAtMs).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaults when triggers empty", async () => {
|
||||||
|
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voicewake-"));
|
||||||
|
const saved = await setVoiceWakeTriggers(["", " "], baseDir);
|
||||||
|
expect(saved.triggers).toEqual(defaultVoiceWakeTriggers());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("diagnostic-events", () => {
|
||||||
|
it("emits monotonic seq", async () => {
|
||||||
|
resetDiagnosticEventsForTest();
|
||||||
|
const seqs: number[] = [];
|
||||||
|
const stop = onDiagnosticEvent((evt) => seqs.push(evt.seq));
|
||||||
|
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "model.usage",
|
||||||
|
usage: { total: 1 },
|
||||||
|
});
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "model.usage",
|
||||||
|
usage: { total: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
stop();
|
||||||
|
|
||||||
|
expect(seqs).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits message-flow events", async () => {
|
||||||
|
resetDiagnosticEventsForTest();
|
||||||
|
const types: string[] = [];
|
||||||
|
const stop = onDiagnosticEvent((evt) => types.push(evt.type));
|
||||||
|
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "webhook.received",
|
||||||
|
channel: "telegram",
|
||||||
|
updateType: "telegram-post",
|
||||||
|
});
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "message.queued",
|
||||||
|
channel: "telegram",
|
||||||
|
source: "telegram",
|
||||||
|
queueDepth: 1,
|
||||||
|
});
|
||||||
|
emitDiagnosticEvent({
|
||||||
|
type: "session.state",
|
||||||
|
state: "processing",
|
||||||
|
reason: "run_started",
|
||||||
|
});
|
||||||
|
|
||||||
|
stop();
|
||||||
|
|
||||||
|
expect(types).toEqual(["webhook.received", "message.queued", "session.state"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("channel activity", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetChannelActivityForTest();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-08T00:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records inbound/outbound separately", () => {
|
||||||
|
recordChannelActivity({ channel: "telegram", direction: "inbound" });
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
recordChannelActivity({ channel: "telegram", direction: "outbound" });
|
||||||
|
const res = getChannelActivity({ channel: "telegram" });
|
||||||
|
expect(res.inboundAt).toBe(1767830400000);
|
||||||
|
expect(res.outboundAt).toBe(1767830401000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates accounts", () => {
|
||||||
|
recordChannelActivity({
|
||||||
|
channel: "whatsapp",
|
||||||
|
accountId: "a",
|
||||||
|
direction: "inbound",
|
||||||
|
at: 1,
|
||||||
|
});
|
||||||
|
recordChannelActivity({
|
||||||
|
channel: "whatsapp",
|
||||||
|
accountId: "b",
|
||||||
|
direction: "inbound",
|
||||||
|
at: 2,
|
||||||
|
});
|
||||||
|
expect(getChannelActivity({ channel: "whatsapp", accountId: "a" })).toEqual({
|
||||||
|
inboundAt: 1,
|
||||||
|
outboundAt: null,
|
||||||
|
});
|
||||||
|
expect(getChannelActivity({ channel: "whatsapp", accountId: "b" })).toEqual({
|
||||||
|
inboundAt: 2,
|
||||||
|
outboundAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createDedupeCache", () => {
|
||||||
|
it("marks duplicates within TTL", () => {
|
||||||
|
const cache = createDedupeCache({ ttlMs: 1000, maxSize: 10 });
|
||||||
|
expect(cache.check("a", 100)).toBe(false);
|
||||||
|
expect(cache.check("a", 500)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires entries after TTL", () => {
|
||||||
|
const cache = createDedupeCache({ ttlMs: 1000, maxSize: 10 });
|
||||||
|
expect(cache.check("a", 100)).toBe(false);
|
||||||
|
expect(cache.check("a", 1501)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evicts oldest entries when over max size", () => {
|
||||||
|
const cache = createDedupeCache({ ttlMs: 10_000, maxSize: 2 });
|
||||||
|
expect(cache.check("a", 100)).toBe(false);
|
||||||
|
expect(cache.check("b", 200)).toBe(false);
|
||||||
|
expect(cache.check("c", 300)).toBe(false);
|
||||||
|
expect(cache.check("a", 400)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes expired entries even when refreshed keys are older in insertion order", () => {
|
||||||
|
const cache = createDedupeCache({ ttlMs: 100, maxSize: 10 });
|
||||||
|
expect(cache.check("a", 0)).toBe(false);
|
||||||
|
expect(cache.check("b", 50)).toBe(false);
|
||||||
|
expect(cache.check("a", 120)).toBe(false);
|
||||||
|
expect(cache.check("c", 200)).toBe(false);
|
||||||
|
expect(cache.size()).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { isMainModule } from "./is-main.js";
|
|
||||||
|
|
||||||
describe("isMainModule", () => {
|
|
||||||
it("returns true when argv[1] matches current file", () => {
|
|
||||||
expect(
|
|
||||||
isMainModule({
|
|
||||||
currentFile: "/repo/dist/index.js",
|
|
||||||
argv: ["node", "/repo/dist/index.js"],
|
|
||||||
cwd: "/repo",
|
|
||||||
env: {},
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true under PM2 when pm_exec_path matches current file", () => {
|
|
||||||
expect(
|
|
||||||
isMainModule({
|
|
||||||
currentFile: "/repo/dist/index.js",
|
|
||||||
argv: ["node", "/pm2/lib/ProcessContainerFork.js"],
|
|
||||||
cwd: "/repo",
|
|
||||||
env: { pm_exec_path: "/repo/dist/index.js", pm_id: "0" },
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when running under PM2 but this module is imported", () => {
|
|
||||||
expect(
|
|
||||||
isMainModule({
|
|
||||||
currentFile: "/repo/node_modules/openclaw/dist/index.js",
|
|
||||||
argv: ["node", "/repo/app.js"],
|
|
||||||
cwd: "/repo",
|
|
||||||
env: { pm_exec_path: "/repo/app.js", pm_id: "0" },
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { buildNodeShellCommand } from "./node-shell.js";
|
|
||||||
|
|
||||||
describe("buildNodeShellCommand", () => {
|
|
||||||
it("uses cmd.exe for win32", () => {
|
|
||||||
expect(buildNodeShellCommand("echo hi", "win32")).toEqual([
|
|
||||||
"cmd.exe",
|
|
||||||
"/d",
|
|
||||||
"/s",
|
|
||||||
"/c",
|
|
||||||
"echo hi",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses cmd.exe for windows labels", () => {
|
|
||||||
expect(buildNodeShellCommand("echo hi", "windows")).toEqual([
|
|
||||||
"cmd.exe",
|
|
||||||
"/d",
|
|
||||||
"/s",
|
|
||||||
"/c",
|
|
||||||
"echo hi",
|
|
||||||
]);
|
|
||||||
expect(buildNodeShellCommand("echo hi", "Windows 11")).toEqual([
|
|
||||||
"cmd.exe",
|
|
||||||
"/d",
|
|
||||||
"/s",
|
|
||||||
"/c",
|
|
||||||
"echo hi",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses /bin/sh for darwin", () => {
|
|
||||||
expect(buildNodeShellCommand("echo hi", "darwin")).toEqual(["/bin/sh", "-lc", "echo hi"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses /bin/sh when platform missing", () => {
|
|
||||||
expect(buildNodeShellCommand("echo hi")).toEqual(["/bin/sh", "-lc", "echo hi"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
__testing,
|
|
||||||
consumeGatewaySigusr1RestartAuthorization,
|
|
||||||
isGatewaySigusr1RestartExternallyAllowed,
|
|
||||||
scheduleGatewaySigusr1Restart,
|
|
||||||
setGatewaySigusr1RestartPolicy,
|
|
||||||
} from "./restart.js";
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("consumes a scheduled authorization once", async () => {
|
|
||||||
expect(consumeGatewaySigusr1RestartAuthorization()).toBe(false);
|
|
||||||
|
|
||||||
scheduleGatewaySigusr1Restart({ delayMs: 0 });
|
|
||||||
|
|
||||||
expect(consumeGatewaySigusr1RestartAuthorization()).toBe(true);
|
|
||||||
expect(consumeGatewaySigusr1RestartAuthorization()).toBe(false);
|
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tracks external restart policy", () => {
|
|
||||||
expect(isGatewaySigusr1RestartExternallyAllowed()).toBe(false);
|
|
||||||
setGatewaySigusr1RestartPolicy({ allowExternal: true });
|
|
||||||
expect(isGatewaySigusr1RestartExternallyAllowed()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { createTelegramRetryRunner } from "./retry-policy.js";
|
|
||||||
|
|
||||||
describe("createTelegramRetryRunner", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retries when custom shouldRetry matches non-telegram error", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const runner = createTelegramRetryRunner({
|
|
||||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
|
||||||
shouldRetry: (err) => err instanceof Error && err.message === "boom",
|
|
||||||
});
|
|
||||||
const fn = vi
|
|
||||||
.fn<[], Promise<string>>()
|
|
||||||
.mockRejectedValueOnce(new Error("boom"))
|
|
||||||
.mockResolvedValue("ok");
|
|
||||||
|
|
||||||
const promise = runner(fn, "request");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
|
|
||||||
await expect(promise).resolves.toBe("ok");
|
|
||||||
expect(fn).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { getShellPathFromLoginShell, resetShellPathCacheForTests } from "./shell-env.js";
|
|
||||||
|
|
||||||
describe("getShellPathFromLoginShell", () => {
|
|
||||||
afterEach(() => resetShellPathCacheForTests());
|
|
||||||
|
|
||||||
it("returns PATH from login shell env", () => {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const exec = vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue(Buffer.from("PATH=/custom/bin\0HOME=/home/user\0", "utf-8"));
|
|
||||||
const result = getShellPathFromLoginShell({ env: { SHELL: "/bin/sh" }, exec });
|
|
||||||
expect(result).toBe("/custom/bin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caches the value", () => {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const exec = vi.fn().mockReturnValue(Buffer.from("PATH=/custom/bin\0", "utf-8"));
|
|
||||||
const env = { SHELL: "/bin/sh" } as NodeJS.ProcessEnv;
|
|
||||||
expect(getShellPathFromLoginShell({ env, exec })).toBe("/custom/bin");
|
|
||||||
expect(getShellPathFromLoginShell({ env, exec })).toBe("/custom/bin");
|
|
||||||
expect(exec).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null on exec failure", () => {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const exec = vi.fn(() => {
|
|
||||||
throw new Error("boom");
|
|
||||||
});
|
|
||||||
const result = getShellPathFromLoginShell({ env: { SHELL: "/bin/sh" }, exec });
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { parseSshTarget } from "./ssh-tunnel.js";
|
|
||||||
|
|
||||||
describe("parseSshTarget", () => {
|
|
||||||
it("parses user@host:port targets", () => {
|
|
||||||
expect(parseSshTarget("me@example.com:2222")).toEqual({
|
|
||||||
user: "me",
|
|
||||||
host: "example.com",
|
|
||||||
port: 2222,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses host-only targets with default port", () => {
|
|
||||||
expect(parseSshTarget("example.com")).toEqual({
|
|
||||||
user: undefined,
|
|
||||||
host: "example.com",
|
|
||||||
port: 22,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects hostnames that start with '-'", () => {
|
|
||||||
expect(parseSshTarget("-V")).toBeNull();
|
|
||||||
expect(parseSshTarget("me@-badhost")).toBeNull();
|
|
||||||
expect(parseSshTarget("-oProxyCommand=echo")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readSessionStoreJson5 } from "./state-migrations.fs.js";
|
|
||||||
|
|
||||||
describe("state migrations fs", () => {
|
|
||||||
it("treats array session stores as invalid", async () => {
|
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-"));
|
|
||||||
const storePath = path.join(dir, "sessions.json");
|
|
||||||
await fs.writeFile(storePath, "[]", "utf-8");
|
|
||||||
|
|
||||||
const result = readSessionStoreJson5(storePath);
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(result.store).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import os from "node:os";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { listTailnetAddresses } from "./tailnet.js";
|
|
||||||
|
|
||||||
describe("tailnet address detection", () => {
|
|
||||||
it("detects tailscale IPv4 and IPv6 addresses", () => {
|
|
||||||
vi.spyOn(os, "networkInterfaces").mockReturnValue({
|
|
||||||
lo0: [
|
|
||||||
{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" },
|
|
||||||
] as unknown as os.NetworkInterfaceInfo[],
|
|
||||||
utun9: [
|
|
||||||
{
|
|
||||||
address: "100.123.224.76",
|
|
||||||
family: "IPv4",
|
|
||||||
internal: false,
|
|
||||||
netmask: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
address: "fd7a:115c:a1e0::8801:e04c",
|
|
||||||
family: "IPv6",
|
|
||||||
internal: false,
|
|
||||||
netmask: "",
|
|
||||||
},
|
|
||||||
] as unknown as os.NetworkInterfaceInfo[],
|
|
||||||
});
|
|
||||||
|
|
||||||
const out = listTailnetAddresses();
|
|
||||||
expect(out.ipv4).toEqual(["100.123.224.76"]);
|
|
||||||
expect(out.ipv6).toEqual(["fd7a:115c:a1e0::8801:e04c"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
defaultVoiceWakeTriggers,
|
|
||||||
loadVoiceWakeConfig,
|
|
||||||
setVoiceWakeTriggers,
|
|
||||||
} from "./voicewake.js";
|
|
||||||
|
|
||||||
describe("voicewake store", () => {
|
|
||||||
it("returns defaults when missing", async () => {
|
|
||||||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voicewake-"));
|
|
||||||
const cfg = await loadVoiceWakeConfig(baseDir);
|
|
||||||
expect(cfg.triggers).toEqual(defaultVoiceWakeTriggers());
|
|
||||||
expect(cfg.updatedAtMs).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sanitizes and persists triggers", async () => {
|
|
||||||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voicewake-"));
|
|
||||||
const saved = await setVoiceWakeTriggers([" hi ", "", " there "], baseDir);
|
|
||||||
expect(saved.triggers).toEqual(["hi", "there"]);
|
|
||||||
expect(saved.updatedAtMs).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const loaded = await loadVoiceWakeConfig(baseDir);
|
|
||||||
expect(loaded.triggers).toEqual(["hi", "there"]);
|
|
||||||
expect(loaded.updatedAtMs).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to defaults when triggers empty", async () => {
|
|
||||||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voicewake-"));
|
|
||||||
const saved = await setVoiceWakeTriggers(["", " "], baseDir);
|
|
||||||
expect(saved.triggers).toEqual(defaultVoiceWakeTriggers());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user