diff --git a/src/infra/binaries.test.ts b/src/infra/binaries.test.ts deleted file mode 100644 index 4deee7bd01..0000000000 --- a/src/infra/binaries.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/infra/channel-activity.test.ts b/src/infra/channel-activity.test.ts deleted file mode 100644 index a12d47bfb6..0000000000 --- a/src/infra/channel-activity.test.ts +++ /dev/null @@ -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, - }); - }); -}); diff --git a/src/infra/dedupe.test.ts b/src/infra/dedupe.test.ts deleted file mode 100644 index 366f0d52fc..0000000000 --- a/src/infra/dedupe.test.ts +++ /dev/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); - }); -}); diff --git a/src/infra/diagnostic-events.test.ts b/src/infra/diagnostic-events.test.ts deleted file mode 100644 index 50fa72e00d..0000000000 --- a/src/infra/diagnostic-events.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/src/infra/diagnostic-flags.test.ts b/src/infra/diagnostic-flags.test.ts deleted file mode 100644 index b2d94a8dae..0000000000 --- a/src/infra/diagnostic-flags.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/infra/infra-parsing.test.ts b/src/infra/infra-parsing.test.ts new file mode 100644 index 0000000000..e9ba7f6d68 --- /dev/null +++ b/src/infra/infra-parsing.test.ts @@ -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(); + }); + }); +}); diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts new file mode 100644 index 0000000000..926c1f224c --- /dev/null +++ b/src/infra/infra-runtime.test.ts @@ -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"]); + }); + }); +}); diff --git a/src/infra/infra-store.test.ts b/src/infra/infra-store.test.ts new file mode 100644 index 0000000000..29c8b87d35 --- /dev/null +++ b/src/infra/infra-store.test.ts @@ -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); + }); + }); +}); diff --git a/src/infra/is-main.test.ts b/src/infra/is-main.test.ts deleted file mode 100644 index a94c2a8162..0000000000 --- a/src/infra/is-main.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/infra/node-shell.test.ts b/src/infra/node-shell.test.ts deleted file mode 100644 index 55683eaba8..0000000000 --- a/src/infra/node-shell.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/src/infra/restart.test.ts b/src/infra/restart.test.ts deleted file mode 100644 index d9d09696e0..0000000000 --- a/src/infra/restart.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/infra/retry-policy.test.ts b/src/infra/retry-policy.test.ts deleted file mode 100644 index 00962367ef..0000000000 --- a/src/infra/retry-policy.test.ts +++ /dev/null @@ -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>() - .mockRejectedValueOnce(new Error("boom")) - .mockResolvedValue("ok"); - - const promise = runner(fn, "request"); - await vi.runAllTimersAsync(); - - await expect(promise).resolves.toBe("ok"); - expect(fn).toHaveBeenCalledTimes(2); - }); -}); diff --git a/src/infra/shell-env.path.test.ts b/src/infra/shell-env.path.test.ts deleted file mode 100644 index 1ae19f0bea..0000000000 --- a/src/infra/shell-env.path.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/infra/ssh-tunnel.test.ts b/src/infra/ssh-tunnel.test.ts deleted file mode 100644 index 10aeb21a34..0000000000 --- a/src/infra/ssh-tunnel.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/infra/state-migrations.fs.test.ts b/src/infra/state-migrations.fs.test.ts deleted file mode 100644 index 0fab215976..0000000000 --- a/src/infra/state-migrations.fs.test.ts +++ /dev/null @@ -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({}); - }); -}); diff --git a/src/infra/tailnet.test.ts b/src/infra/tailnet.test.ts deleted file mode 100644 index 15c18368f8..0000000000 --- a/src/infra/tailnet.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/src/infra/voicewake.test.ts b/src/infra/voicewake.test.ts deleted file mode 100644 index 55665b7ea7..0000000000 --- a/src/infra/voicewake.test.ts +++ /dev/null @@ -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()); - }); -});