From fef688fb7a217992f65745db27da5c227201ea2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 28 Mar 2026 00:26:10 +0000 Subject: [PATCH] test: dedupe utility and config suites --- src/auto-reply/chunk.test.ts | 156 +++-- src/channels/channel-config.test.ts | 8 +- src/channels/channels-misc.test.ts | 15 +- src/channels/conversation-label.test.ts | 15 +- src/channels/model-overrides.test.ts | 16 +- src/channels/status-reactions.test.ts | 19 +- src/cli/argv.test.ts | 172 ++--- src/cli/cli-utils.test.ts | 64 +- src/cli/update-cli.test.ts | 512 ++++++++------- ...etection.rejects-routing-allowfrom.test.ts | 443 +++++++------ src/config/includes.test.ts | 589 +++++++++--------- src/config/redact-snapshot.test.ts | 78 ++- src/hooks/internal-hooks.test.ts | 90 ++- src/infra/exec-allowlist-matching.test.ts | 56 +- src/infra/exec-inline-eval.test.ts | 21 +- src/infra/npm-integrity.test.ts | 46 +- src/logger.test.ts | 28 +- src/markdown/whatsapp.test.ts | 39 +- src/media/mime.test.ts | 16 +- src/pairing/pairing-messages.test.ts | 20 +- src/routing/resolve-route.test.ts | 16 +- src/shared/text/reasoning-tags.test.ts | 39 +- src/utils/reaction-level.test.ts | 12 +- src/utils/utils-misc.test.ts | 20 +- 24 files changed, 1178 insertions(+), 1312 deletions(-) diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 07b40069d57..e7515789cb3 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -25,10 +25,29 @@ type ChunkCase = { }; function runChunkCases(chunker: (text: string, limit: number) => string[], cases: ChunkCase[]) { - for (const { name, text, limit, expected } of cases) { - it(name, () => { - expect(chunker(text, limit)).toEqual(expected); - }); + it.each(cases)("$name", ({ text, limit, expected }) => { + expect(chunker(text, limit)).toEqual(expected); + }); +} + +function expectMarkdownFenceSplitCases( + cases: ReadonlyArray<{ + name: string; + text: string; + limit: number; + expectedPrefix: string; + expectedSuffix: string; + }>, +) { + for (const { name, text, limit, expectedPrefix, expectedSuffix } of cases) { + const chunks = chunkMarkdownText(text, limit); + expect(chunks.length, name).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length, name).toBeLessThanOrEqual(limit); + expect(chunk.startsWith(expectedPrefix), name).toBe(true); + expect(chunk.trimEnd().endsWith(expectedSuffix), name).toBe(true); + } + expectFencesBalanced(chunks); } } @@ -187,16 +206,7 @@ describe("chunkMarkdownText", () => { }, ] as const; - for (const testCase of cases) { - const chunks = chunkMarkdownText(testCase.text, testCase.limit); - expect(chunks.length, testCase.name).toBeGreaterThan(1); - for (const chunk of chunks) { - expect(chunk.length, testCase.name).toBeLessThanOrEqual(testCase.limit); - expect(chunk.startsWith(testCase.expectedPrefix), testCase.name).toBe(true); - expect(chunk.trimEnd().endsWith(testCase.expectedSuffix), testCase.name).toBe(true); - } - expectFencesBalanced(chunks); - } + expectMarkdownFenceSplitCases(cases); }); it("never produces an empty fenced chunk when splitting", () => { @@ -273,10 +283,8 @@ describe("chunkByNewline", () => { expect(chunks).toEqual([text]); }); - it("returns empty array for empty and whitespace-only input", () => { - for (const text of ["", " \n\n "]) { - expect(chunkByNewline(text, 100)).toEqual([]); - } + it.each(["", " \n\n "] as const)("returns empty array for input %j", (text) => { + expect(chunkByNewline(text, 100)).toEqual([]); }); it("preserves trailing blank lines on the last chunk", () => { @@ -293,62 +301,55 @@ describe("chunkByNewline", () => { }); describe("chunkTextWithMode", () => { - it("applies mode-specific chunking behavior", () => { - const cases = [ - { - name: "length mode", - text: "Line one\nLine two", - mode: "length" as const, - expected: ["Line one\nLine two"], - }, - { - name: "newline mode (single paragraph)", - text: "Line one\nLine two", - mode: "newline" as const, - expected: ["Line one\nLine two"], - }, - { - name: "newline mode (blank-line split)", - text: "Para one\n\nPara two", - mode: "newline" as const, - expected: ["Para one", "Para two"], - }, - ] as const; - - for (const testCase of cases) { - const chunks = chunkTextWithMode(testCase.text, 1000, testCase.mode); - expect(chunks, testCase.name).toEqual(testCase.expected); - } - }); + it.each([ + { + name: "length mode", + text: "Line one\nLine two", + mode: "length" as const, + expected: ["Line one\nLine two"], + }, + { + name: "newline mode (single paragraph)", + text: "Line one\nLine two", + mode: "newline" as const, + expected: ["Line one\nLine two"], + }, + { + name: "newline mode (blank-line split)", + text: "Para one\n\nPara two", + mode: "newline" as const, + expected: ["Para one", "Para two"], + }, + ] as const)( + "applies mode-specific chunking behavior: $name", + ({ text, mode, expected, name }) => { + expect(chunkTextWithMode(text, 1000, mode), name).toEqual(expected); + }, + ); }); describe("chunkMarkdownTextWithMode", () => { - it("applies markdown/newline mode behavior", () => { - const cases = [ - { - name: "length mode uses markdown-aware chunker", - text: "Line one\nLine two", - mode: "length" as const, - expected: chunkMarkdownText("Line one\nLine two", 1000), - }, - { - name: "newline mode keeps single paragraph", - text: "Line one\nLine two", - mode: "newline" as const, - expected: ["Line one\nLine two"], - }, - { - name: "newline mode splits by blank line", - text: "Para one\n\nPara two", - mode: "newline" as const, - expected: ["Para one", "Para two"], - }, - ] as const; - for (const testCase of cases) { - expect(chunkMarkdownTextWithMode(testCase.text, 1000, testCase.mode), testCase.name).toEqual( - testCase.expected, - ); - } + it.each([ + { + name: "length mode uses markdown-aware chunker", + text: "Line one\nLine two", + mode: "length" as const, + expected: chunkMarkdownText("Line one\nLine two", 1000), + }, + { + name: "newline mode keeps single paragraph", + text: "Line one\nLine two", + mode: "newline" as const, + expected: ["Line one\nLine two"], + }, + { + name: "newline mode splits by blank line", + text: "Para one\n\nPara two", + mode: "newline" as const, + expected: ["Para one", "Para two"], + }, + ] as const)("applies markdown/newline mode behavior: $name", ({ text, mode, expected, name }) => { + expect(chunkMarkdownTextWithMode(text, 1000, mode), name).toEqual(expected); }); it("handles newline mode fence splitting rules", () => { @@ -381,11 +382,8 @@ describe("chunkMarkdownTextWithMode", () => { }, ] as const; - for (const testCase of cases) { - expect( - chunkMarkdownTextWithMode(testCase.text, testCase.limit, "newline"), - testCase.name, - ).toEqual(testCase.expected); + for (const { text, limit, expected, name } of cases) { + expect(chunkMarkdownTextWithMode(text, limit, "newline"), name).toEqual(expected); } }); }); @@ -414,10 +412,8 @@ describe("resolveChunkMode", () => { { cfg: accountCfg, provider: "slack", accountId: "other", expected: "length" }, ] as const; - for (const testCase of cases) { - expect(resolveChunkMode(testCase.cfg as never, testCase.provider, testCase.accountId)).toBe( - testCase.expected, - ); + for (const { cfg, provider, accountId, expected } of cases) { + expect(resolveChunkMode(cfg as never, provider, accountId)).toBe(expected); } }); }); diff --git a/src/channels/channel-config.test.ts b/src/channels/channel-config.test.ts index 38b80332f63..8486814e563 100644 --- a/src/channels/channel-config.test.ts +++ b/src/channels/channel-config.test.ts @@ -208,9 +208,7 @@ describe("resolveNestedAllowlistDecision", () => { }, ] as const; - for (const testCase of cases) { - it(testCase.name, () => { - expect(resolveNestedAllowlistDecision(testCase.value)).toBe(testCase.expected); - }); - } + it.each(cases)("$name", ({ value, expected }) => { + expect(resolveNestedAllowlistDecision(value)).toBe(expected); + }); }); diff --git a/src/channels/channels-misc.test.ts b/src/channels/channels-misc.test.ts index 7d5ba3f355c..26bc327f146 100644 --- a/src/channels/channels-misc.test.ts +++ b/src/channels/channels-misc.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeChatType } from "./chat-type.js"; describe("normalizeChatType", () => { - const cases: Array<{ name: string; value: string | undefined; expected: string | undefined }> = [ + it.each([ { name: "normalizes direct", value: "direct", expected: "direct" }, { name: "normalizes dm alias", value: "dm", expected: "direct" }, { name: "normalizes group", value: "group", expected: "group" }, @@ -11,13 +11,12 @@ describe("normalizeChatType", () => { { name: "returns undefined for empty", value: "", expected: undefined }, { name: "returns undefined for unknown value", value: "nope", expected: undefined }, { name: "returns undefined for unsupported room", value: "room", expected: undefined }, - ]; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(normalizeChatType(testCase.value)).toBe(testCase.expected); - }); - } + ] satisfies Array<{ name: string; value: string | undefined; expected: string | undefined }>)( + "$name", + ({ value, expected }) => { + expect(normalizeChatType(value)).toBe(expected); + }, + ); describe("backward compatibility", () => { it("accepts legacy 'dm' value shape variants and normalizes to 'direct'", () => { diff --git a/src/channels/conversation-label.test.ts b/src/channels/conversation-label.test.ts index 2852b4a9936..16ba4cd6356 100644 --- a/src/channels/conversation-label.test.ts +++ b/src/channels/conversation-label.test.ts @@ -3,7 +3,7 @@ import type { MsgContext } from "../auto-reply/templating.js"; import { resolveConversationLabel } from "./conversation-label.js"; describe("resolveConversationLabel", () => { - const cases: Array<{ name: string; ctx: MsgContext; expected: string }> = [ + it.each([ { name: "prefers ConversationLabel when present", ctx: { ConversationLabel: "Pinned Label", ChatType: "group" }, @@ -61,11 +61,10 @@ describe("resolveConversationLabel", () => { }, expected: "Family id:123@g.us", }, - ]; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(resolveConversationLabel(testCase.ctx)).toBe(testCase.expected); - }); - } + ] satisfies Array<{ name: string; ctx: MsgContext; expected: string }>)( + "$name", + ({ ctx, expected }) => { + expect(resolveConversationLabel(ctx)).toBe(expected); + }, + ); }); diff --git a/src/channels/model-overrides.test.ts b/src/channels/model-overrides.test.ts index 71e852d3f24..ea28fc464f9 100644 --- a/src/channels/model-overrides.test.ts +++ b/src/channels/model-overrides.test.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelModelOverride } from "./model-overrides.js"; describe("resolveChannelModelOverride", () => { - const cases = [ + it.each([ { name: "matches parent group id when topic suffix is present", input: { @@ -57,13 +57,9 @@ describe("resolveChannelModelOverride", () => { }, expected: { model: "demo-provider/demo-parent-model", matchKey: "123" }, }, - ] as const; - - for (const testCase of cases) { - it(testCase.name, () => { - const resolved = resolveChannelModelOverride(testCase.input); - expect(resolved?.model).toBe(testCase.expected.model); - expect(resolved?.matchKey).toBe(testCase.expected.matchKey); - }); - } + ] as const)("$name", ({ input, expected }) => { + const resolved = resolveChannelModelOverride(input); + expect(resolved?.model).toBe(expected.model); + expect(resolved?.matchKey).toBe(expected.matchKey); + }); }); diff --git a/src/channels/status-reactions.test.ts b/src/channels/status-reactions.test.ts index 41611c22b1a..a96be801f55 100644 --- a/src/channels/status-reactions.test.ts +++ b/src/channels/status-reactions.test.ts @@ -61,11 +61,7 @@ const createSetOnlyController = () => { // ───────────────────────────────────────────────────────────────────────────── describe("resolveToolEmoji", () => { - const cases: Array<{ - name: string; - tool: string | undefined; - expected: string; - }> = [ + it.each([ { name: "returns coding emoji for exec tool", tool: "exec", expected: DEFAULT_EMOJIS.coding }, { name: "returns coding emoji for process tool", @@ -91,13 +87,12 @@ describe("resolveToolEmoji", () => { tool: "my_exec_wrapper", expected: DEFAULT_EMOJIS.coding, }, - ]; - - for (const testCase of cases) { - it(`should ${testCase.name}`, () => { - expect(resolveToolEmoji(testCase.tool, DEFAULT_EMOJIS)).toBe(testCase.expected); - }); - } + ] satisfies Array<{ name: string; tool: string | undefined; expected: string }>)( + "should $name", + ({ tool, expected }) => { + expect(resolveToolEmoji(tool, DEFAULT_EMOJIS)).toBe(expected); + }, + ); }); describe("createStatusReactionController", () => { diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 45caaab3704..002105b768d 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -316,69 +316,78 @@ describe("argv helpers", () => { expect(getPositiveIntFlagValue(argv, "--timeout")).toBe(expected); }); - it("builds parse argv from raw args", () => { - const cases = [ - { - rawArgs: ["node", "openclaw", "status"], - expected: ["node", "openclaw", "status"], - }, - { - rawArgs: ["node-22", "openclaw", "status"], - expected: ["node-22", "openclaw", "status"], - }, - { - rawArgs: ["node-22.2.0.exe", "openclaw", "status"], - expected: ["node-22.2.0.exe", "openclaw", "status"], - }, - { - rawArgs: ["node-22.2", "openclaw", "status"], - expected: ["node-22.2", "openclaw", "status"], - }, - { - rawArgs: ["node-22.2.exe", "openclaw", "status"], - expected: ["node-22.2.exe", "openclaw", "status"], - }, - { - rawArgs: ["/usr/bin/node-22.2.0", "openclaw", "status"], - expected: ["/usr/bin/node-22.2.0", "openclaw", "status"], - }, - { - rawArgs: ["node24", "openclaw", "status"], - expected: ["node24", "openclaw", "status"], - }, - { - rawArgs: ["/usr/bin/node24", "openclaw", "status"], - expected: ["/usr/bin/node24", "openclaw", "status"], - }, - { - rawArgs: ["node24.exe", "openclaw", "status"], - expected: ["node24.exe", "openclaw", "status"], - }, - { - rawArgs: ["nodejs", "openclaw", "status"], - expected: ["nodejs", "openclaw", "status"], - }, - { - rawArgs: ["node-dev", "openclaw", "status"], - expected: ["node", "openclaw", "node-dev", "openclaw", "status"], - }, - { - rawArgs: ["openclaw", "status"], - expected: ["node", "openclaw", "status"], - }, - { - rawArgs: ["bun", "src/entry.ts", "status"], - expected: ["bun", "src/entry.ts", "status"], - }, - ] as const; - - for (const testCase of cases) { - const parsed = buildParseArgv({ - programName: "openclaw", - rawArgs: [...testCase.rawArgs], - }); - expect(parsed).toEqual([...testCase.expected]); - } + it.each([ + { + name: "keeps plain node argv", + rawArgs: ["node", "openclaw", "status"], + expected: ["node", "openclaw", "status"], + }, + { + name: "keeps version-suffixed node binary", + rawArgs: ["node-22", "openclaw", "status"], + expected: ["node-22", "openclaw", "status"], + }, + { + name: "keeps windows versioned node exe", + rawArgs: ["node-22.2.0.exe", "openclaw", "status"], + expected: ["node-22.2.0.exe", "openclaw", "status"], + }, + { + name: "keeps dotted node binary", + rawArgs: ["node-22.2", "openclaw", "status"], + expected: ["node-22.2", "openclaw", "status"], + }, + { + name: "keeps dotted node exe", + rawArgs: ["node-22.2.exe", "openclaw", "status"], + expected: ["node-22.2.exe", "openclaw", "status"], + }, + { + name: "keeps absolute versioned node path", + rawArgs: ["/usr/bin/node-22.2.0", "openclaw", "status"], + expected: ["/usr/bin/node-22.2.0", "openclaw", "status"], + }, + { + name: "keeps node24 shorthand", + rawArgs: ["node24", "openclaw", "status"], + expected: ["node24", "openclaw", "status"], + }, + { + name: "keeps absolute node24 shorthand", + rawArgs: ["/usr/bin/node24", "openclaw", "status"], + expected: ["/usr/bin/node24", "openclaw", "status"], + }, + { + name: "keeps windows node24 exe", + rawArgs: ["node24.exe", "openclaw", "status"], + expected: ["node24.exe", "openclaw", "status"], + }, + { + name: "keeps nodejs binary", + rawArgs: ["nodejs", "openclaw", "status"], + expected: ["nodejs", "openclaw", "status"], + }, + { + name: "prefixes fallback when first arg is not a node launcher", + rawArgs: ["node-dev", "openclaw", "status"], + expected: ["node", "openclaw", "node-dev", "openclaw", "status"], + }, + { + name: "prefixes fallback when raw args start at program name", + rawArgs: ["openclaw", "status"], + expected: ["node", "openclaw", "status"], + }, + { + name: "keeps bun execution argv", + rawArgs: ["bun", "src/entry.ts", "status"], + expected: ["bun", "src/entry.ts", "status"], + }, + ] as const)("builds parse argv from raw args: $name", ({ rawArgs, expected }) => { + const parsed = buildParseArgv({ + programName: "openclaw", + rawArgs: [...rawArgs], + }); + expect(parsed).toEqual([...expected]); }); it("builds parse argv from fallback args", () => { @@ -389,29 +398,20 @@ describe("argv helpers", () => { expect(fallbackArgv).toEqual(["node", "openclaw", "status"]); }); - it("decides when to migrate state", () => { - const nonMutatingArgv = [ - ["node", "openclaw", "status"], - ["node", "openclaw", "health"], - ["node", "openclaw", "sessions"], - ["node", "openclaw", "config", "get", "update"], - ["node", "openclaw", "config", "unset", "update"], - ["node", "openclaw", "models", "list"], - ["node", "openclaw", "models", "status"], - ["node", "openclaw", "update", "status", "--json"], - ["node", "openclaw", "agent", "--message", "hi"], - ] as const; - const mutatingArgv = [ - ["node", "openclaw", "agents", "list"], - ["node", "openclaw", "message", "send"], - ] as const; - - for (const argv of nonMutatingArgv) { - expect(shouldMigrateState([...argv])).toBe(false); - } - for (const argv of mutatingArgv) { - expect(shouldMigrateState([...argv])).toBe(true); - } + it.each([ + { argv: ["node", "openclaw", "status"], expected: false }, + { argv: ["node", "openclaw", "health"], expected: false }, + { argv: ["node", "openclaw", "sessions"], expected: false }, + { argv: ["node", "openclaw", "config", "get", "update"], expected: false }, + { argv: ["node", "openclaw", "config", "unset", "update"], expected: false }, + { argv: ["node", "openclaw", "models", "list"], expected: false }, + { argv: ["node", "openclaw", "models", "status"], expected: false }, + { argv: ["node", "openclaw", "update", "status", "--json"], expected: false }, + { argv: ["node", "openclaw", "agent", "--message", "hi"], expected: false }, + { argv: ["node", "openclaw", "agents", "list"], expected: true }, + { argv: ["node", "openclaw", "message", "send"], expected: true }, + ] as const)("decides when to migrate state: $argv", ({ argv, expected }) => { + expect(shouldMigrateState([...argv])).toBe(expected); }); it.each([ diff --git a/src/cli/cli-utils.test.ts b/src/cli/cli-utils.test.ts index 69f65cfb3fb..76247f54347 100644 --- a/src/cli/cli-utils.test.ts +++ b/src/cli/cli-utils.test.ts @@ -19,14 +19,11 @@ describe("waitForever", () => { }); describe("shouldSkipRespawnForArgv", () => { - it("skips respawn for help/version calls", () => { - const cases = [ - ["node", "openclaw", "--help"], - ["node", "openclaw", "-V"], - ] as const; - for (const argv of cases) { - expect(shouldSkipRespawnForArgv([...argv]), argv.join(" ")).toBe(true); - } + it.each([ + { argv: ["node", "openclaw", "--help"] }, + { argv: ["node", "openclaw", "-V"] }, + ] as const)("skips respawn for argv %j", ({ argv }) => { + expect(shouldSkipRespawnForArgv([...argv]), argv.join(" ")).toBe(true); }); it("keeps respawn path for normal commands", () => { @@ -66,46 +63,37 @@ describe("dns cli", () => { }); describe("parseByteSize", () => { - it("parses byte-size units and shorthand values", () => { - const cases = [ - ["parses 10kb", "10kb", 10 * 1024], - ["parses 1mb", "1mb", 1024 * 1024], - ["parses 2gb", "2gb", 2 * 1024 * 1024 * 1024], - ["parses shorthand 5k", "5k", 5 * 1024], - ["parses shorthand 1m", "1m", 1024 * 1024], - ] as const; - for (const [name, input, expected] of cases) { - expect(parseByteSize(input), name).toBe(expected); - } + it.each([ + ["parses 10kb", "10kb", 10 * 1024], + ["parses 1mb", "1mb", 1024 * 1024], + ["parses 2gb", "2gb", 2 * 1024 * 1024 * 1024], + ["parses shorthand 5k", "5k", 5 * 1024], + ["parses shorthand 1m", "1m", 1024 * 1024], + ] as const)("%s", (_name, input, expected) => { + expect(parseByteSize(input)).toBe(expected); }); it("uses default unit when omitted", () => { expect(parseByteSize("123")).toBe(123); }); - it("rejects invalid values", () => { - const cases = ["", "nope", "-5kb"] as const; - for (const input of cases) { - expect(() => parseByteSize(input), input || "").toThrow(); - } + it.each(["", "nope", "-5kb"] as const)("rejects invalid value %j", (input) => { + expect(() => parseByteSize(input)).toThrow(); }); }); describe("parseDurationMs", () => { - it("parses duration strings", () => { - const cases = [ - ["parses bare ms", "10000", 10_000], - ["parses seconds suffix", "10s", 10_000], - ["parses minutes suffix", "1m", 60_000], - ["parses hours suffix", "2h", 7_200_000], - ["parses days suffix", "2d", 172_800_000], - ["supports decimals", "0.5s", 500], - ["parses composite hours+minutes", "1h30m", 5_400_000], - ["parses composite with milliseconds", "2m500ms", 120_500], - ] as const; - for (const [name, input, expected] of cases) { - expect(parseDurationMs(input), name).toBe(expected); - } + it.each([ + ["parses bare ms", "10000", 10_000], + ["parses seconds suffix", "10s", 10_000], + ["parses minutes suffix", "1m", 60_000], + ["parses hours suffix", "2h", 7_200_000], + ["parses days suffix", "2d", 172_800_000], + ["supports decimals", "0.5s", 500], + ["parses composite hours+minutes", "1h30m", 5_400_000], + ["parses composite with milliseconds", "2m500ms", 120_500], + ] as const)("%s", (_name, input, expected) => { + expect(parseDurationMs(input)).toBe(expected); }); it("rejects invalid composite strings", () => { diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index a764b90a660..fa2801988b3 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -159,6 +159,12 @@ const { defaultRuntime } = await import("../runtime.js"); const { updateCommand, updateStatusCommand, updateWizardCommand } = await import("./update-cli.js"); const { resolveGitInstallDir } = await import("./update-cli/shared.js"); +type UpdateCliScenario = { + name: string; + run: () => Promise; + assert: () => void; +}; + describe("update-cli", () => { const fixtureRoot = "/tmp/openclaw-update-tests"; let fixtureCount = 0; @@ -235,6 +241,12 @@ describe("update-cli", () => { ...overrides, }) as UpdateRunResult; + const runUpdateCliScenario = async (testCase: UpdateCliScenario) => { + vi.clearAllMocks(); + await testCase.run(); + testCase.assert(); + }; + const runRestartFallbackScenario = async (params: { daemonInstall: "ok" | "fail" }) => { vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); if (params.daemonInstall === "fail") { @@ -396,79 +408,67 @@ describe("update-cli", () => { setStdoutTty(false); }); - it("updateCommand dry-run previews without mutating and bypasses downgrade confirmation", async () => { - const cases = [ - { - name: "preview mode", - run: async () => { - vi.mocked(defaultRuntime.log).mockClear(); - serviceLoaded.mockResolvedValue(true); - await updateCommand({ dryRun: true, channel: "beta" }); - }, - assert: () => { - expect(writeConfigFile).not.toHaveBeenCalled(); - expect(runGatewayUpdate).not.toHaveBeenCalled(); - expect(runDaemonInstall).not.toHaveBeenCalled(); - expect(runRestartScript).not.toHaveBeenCalled(); - expect(runDaemonRestart).not.toHaveBeenCalled(); - - const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); - expect(logs.join("\n")).toContain("Update dry-run"); - expect(logs.join("\n")).toContain("No changes were applied."); - }, + it.each([ + { + name: "preview mode", + run: async () => { + vi.mocked(defaultRuntime.log).mockClear(); + serviceLoaded.mockResolvedValue(true); + await updateCommand({ dryRun: true, channel: "beta" }); }, - { - name: "downgrade bypass", - run: async () => { - await setupNonInteractiveDowngrade(); - vi.mocked(defaultRuntime.exit).mockClear(); - await updateCommand({ dryRun: true }); - }, - assert: () => { - expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe( - false, - ); - expect(runGatewayUpdate).not.toHaveBeenCalled(); - }, - }, - ] as const; + assert: () => { + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); - for (const testCase of cases) { - vi.clearAllMocks(); - await testCase.run(); - testCase.assert(); - } - }); - - it("updateStatusCommand renders table and json output", async () => { - const cases = [ - { - name: "table output", - options: { json: false }, - assert: () => { - const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]); - expect(logs.join("\n")).toContain("OpenClaw update status"); - }, + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.join("\n")).toContain("Update dry-run"); + expect(logs.join("\n")).toContain("No changes were applied."); }, - { - name: "json output", - options: { json: true }, - assert: () => { - const last = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0]; - expect(last).toBeDefined(); - const parsed = last as Record; - const channel = parsed.channel as { value?: unknown }; - expect(channel.value).toBe("stable"); - }, + }, + { + name: "downgrade bypass", + run: async () => { + await setupNonInteractiveDowngrade(); + vi.mocked(defaultRuntime.exit).mockClear(); + await updateCommand({ dryRun: true }); }, - ] as const; + assert: () => { + expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + }, + }, + ] as const)("updateCommand dry-run behavior: $name", runUpdateCliScenario); - for (const testCase of cases) { - vi.mocked(defaultRuntime.log).mockClear(); - await updateStatusCommand(testCase.options); - testCase.assert(); - } - }); + it.each([ + { + name: "table output", + run: async () => { + vi.mocked(defaultRuntime.log).mockClear(); + await updateStatusCommand({ json: false }); + }, + assert: () => { + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]); + expect(logs.join("\n")).toContain("OpenClaw update status"); + }, + }, + { + name: "json output", + run: async () => { + vi.mocked(defaultRuntime.log).mockClear(); + await updateStatusCommand({ json: true }); + }, + assert: () => { + const last = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0]; + expect(last).toBeDefined(); + const parsed = last as Record; + const channel = parsed.channel as { value?: unknown }; + expect(channel.value).toBe("stable"); + }, + }, + ] as const)("updateStatusCommand rendering: $name", runUpdateCliScenario); it("parses update status --json as the subcommand option", async () => { const program = new Command(); @@ -607,55 +607,56 @@ describe("update-cli", () => { ); }); - it("resolves package install specs from tags and env overrides", async () => { - for (const scenario of [ - { - name: "explicit dist-tag", - run: async () => { - mockPackageInstallStatus(createCaseDir("openclaw-update")); - await updateCommand({ tag: "next" }); - }, - expectedSpec: "openclaw@next", + it.each([ + { + name: "explicit dist-tag", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await updateCommand({ tag: "next" }); }, - { - name: "main shorthand", - run: async () => { - mockPackageInstallStatus(createCaseDir("openclaw-update")); - await updateCommand({ yes: true, tag: "main" }); - }, - expectedSpec: "github:openclaw/openclaw#main", + expectedSpec: "openclaw@next", + }, + { + name: "main shorthand", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await updateCommand({ yes: true, tag: "main" }); }, - { - name: "explicit git package spec", - run: async () => { - mockPackageInstallStatus(createCaseDir("openclaw-update")); - await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" }); - }, - expectedSpec: "github:openclaw/openclaw#main", + expectedSpec: "github:openclaw/openclaw#main", + }, + { + name: "explicit git package spec", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" }); }, - { - name: "OPENCLAW_UPDATE_PACKAGE_SPEC override", - run: async () => { - mockPackageInstallStatus(createCaseDir("openclaw-update")); - await withEnvAsync( - { OPENCLAW_UPDATE_PACKAGE_SPEC: "http://10.211.55.2:8138/openclaw-next.tgz" }, - async () => { - await updateCommand({ yes: true, tag: "latest" }); - }, - ); - }, - expectedSpec: "http://10.211.55.2:8138/openclaw-next.tgz", + expectedSpec: "github:openclaw/openclaw#main", + }, + { + name: "OPENCLAW_UPDATE_PACKAGE_SPEC override", + run: async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + await withEnvAsync( + { OPENCLAW_UPDATE_PACKAGE_SPEC: "http://10.211.55.2:8138/openclaw-next.tgz" }, + async () => { + await updateCommand({ yes: true, tag: "latest" }); + }, + ); }, - ]) { + expectedSpec: "http://10.211.55.2:8138/openclaw-next.tgz", + }, + ] as const)( + "resolves package install specs from tags and env overrides: $name", + async ({ run, expectedSpec }) => { vi.clearAllMocks(); readPackageName.mockResolvedValue("openclaw"); readPackageVersion.mockResolvedValue("1.0.0"); resolveGlobalManager.mockResolvedValue("npm"); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd()); - await scenario.run(); - expectPackageInstallSpec(scenario.expectedSpec); - } - }); + await run(); + expectPackageInstallSpec(expectedSpec); + }, + ); it("fails package updates when the installed correction version does not match the requested target", async () => { const tempDir = createCaseDir("openclaw-update"); @@ -762,45 +763,37 @@ describe("update-cli", () => { expect(updateOptions?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1"); }); - it("updateCommand reports success and failure outcomes", async () => { - const cases = [ - { - name: "outputs JSON when --json is set", - run: async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - vi.mocked(defaultRuntime.writeJson).mockClear(); - await updateCommand({ json: true }); - }, - assert: () => { - const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0]; - expect(jsonOutput).toBeDefined(); - }, + it.each([ + { + name: "outputs JSON when --json is set", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); + vi.mocked(defaultRuntime.writeJson).mockClear(); + await updateCommand({ json: true }); }, - { - name: "exits with error on failure", - run: async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "error", - mode: "git", - reason: "rebase-failed", - steps: [], - durationMs: 100, - } satisfies UpdateRunResult); - vi.mocked(defaultRuntime.exit).mockClear(); - await updateCommand({}); - }, - assert: () => { - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); - }, + assert: () => { + const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0]; + expect(jsonOutput).toBeDefined(); }, - ] as const; - - for (const testCase of cases) { - vi.clearAllMocks(); - await testCase.run(); - testCase.assert(); - } - }); + }, + { + name: "exits with error on failure", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "error", + mode: "git", + reason: "rebase-failed", + steps: [], + durationMs: 100, + } satisfies UpdateRunResult); + vi.mocked(defaultRuntime.exit).mockClear(); + await updateCommand({}); + }, + assert: () => { + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }, + }, + ] as const)("updateCommand reports outcomes: $name", runUpdateCliScenario); it("persists the requested channel only after a successful package update", async () => { const tempDir = createCaseDir("openclaw-update"); @@ -888,96 +881,88 @@ describe("update-cli", () => { expect(lastWrite?.update?.channel).toBe("beta"); }); - it("updateCommand handles service env refresh and restart behavior", async () => { - const cases = [ - { - name: "refreshes service env when already installed", - run: async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - } satisfies UpdateRunResult); - vi.mocked(runDaemonInstall).mockResolvedValue(undefined); - serviceLoaded.mockResolvedValue(true); + it.each([ + { + name: "refreshes service env when already installed", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + } satisfies UpdateRunResult); + vi.mocked(runDaemonInstall).mockResolvedValue(undefined); + serviceLoaded.mockResolvedValue(true); - await updateCommand({}); - }, - assert: () => { - expect(runDaemonInstall).toHaveBeenCalledWith({ - force: true, - json: undefined, - }); - expect(runRestartScript).toHaveBeenCalled(); - expect(runDaemonRestart).not.toHaveBeenCalled(); - }, + await updateCommand({}); }, - { - name: "falls back to daemon restart when service env refresh cannot complete", - run: async () => { - vi.mocked(runDaemonRestart).mockResolvedValue(true); - await runRestartFallbackScenario({ daemonInstall: "fail" }); - }, - assert: () => { - expect(runDaemonInstall).toHaveBeenCalledWith({ - force: true, - json: undefined, - }); - expect(runDaemonRestart).toHaveBeenCalled(); - }, + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith({ + force: true, + json: undefined, + }); + expect(runRestartScript).toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); }, - { - name: "keeps going when daemon install succeeds but restart fallback still handles relaunch", - run: async () => { - vi.mocked(runDaemonRestart).mockResolvedValue(true); - await runRestartFallbackScenario({ daemonInstall: "ok" }); - }, - assert: () => { - expect(runDaemonInstall).toHaveBeenCalledWith({ - force: true, - json: undefined, - }); - expect(runDaemonRestart).toHaveBeenCalled(); - }, + }, + { + name: "falls back to daemon restart when service env refresh cannot complete", + run: async () => { + vi.mocked(runDaemonRestart).mockResolvedValue(true); + await runRestartFallbackScenario({ daemonInstall: "fail" }); }, - { - name: "skips service env refresh when --no-restart is set", - run: async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - serviceLoaded.mockResolvedValue(true); + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith({ + force: true, + json: undefined, + }); + expect(runDaemonRestart).toHaveBeenCalled(); + }, + }, + { + name: "keeps going when daemon install succeeds but restart fallback still handles relaunch", + run: async () => { + vi.mocked(runDaemonRestart).mockResolvedValue(true); + await runRestartFallbackScenario({ daemonInstall: "ok" }); + }, + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith({ + force: true, + json: undefined, + }); + expect(runDaemonRestart).toHaveBeenCalled(); + }, + }, + { + name: "skips service env refresh when --no-restart is set", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); + serviceLoaded.mockResolvedValue(true); - await updateCommand({ restart: false }); - }, - assert: () => { - expect(runDaemonInstall).not.toHaveBeenCalled(); - expect(runRestartScript).not.toHaveBeenCalled(); - expect(runDaemonRestart).not.toHaveBeenCalled(); - }, + await updateCommand({ restart: false }); }, - { - name: "skips success message when restart does not run", - run: async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - vi.mocked(runDaemonRestart).mockResolvedValue(false); - vi.mocked(defaultRuntime.log).mockClear(); - await updateCommand({ restart: true }); - }, - assert: () => { - const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); - expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe( - false, - ); - }, + assert: () => { + expect(runDaemonInstall).not.toHaveBeenCalled(); + expect(runRestartScript).not.toHaveBeenCalled(); + expect(runDaemonRestart).not.toHaveBeenCalled(); }, - ] as const; - - for (const testCase of cases) { - vi.clearAllMocks(); - await testCase.run(); - testCase.assert(); - } - }); + }, + { + name: "skips success message when restart does not run", + run: async () => { + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); + vi.mocked(runDaemonRestart).mockResolvedValue(false); + vi.mocked(defaultRuntime.log).mockClear(); + await updateCommand({ restart: true }); + }, + assert: () => { + const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe( + false, + ); + }, + }, + ] as const)("updateCommand service refresh behavior: $name", runUpdateCliScenario); it.each([ { @@ -1109,47 +1094,46 @@ describe("update-cli", () => { } }); - it("validates update command invocation errors", async () => { - const cases = [ - { - name: "update command invalid timeout", - run: async () => await updateCommand({ timeout: "invalid" }), - requireTty: false, - expectedError: "timeout", - }, - { - name: "update status command invalid timeout", - run: async () => await updateStatusCommand({ timeout: "invalid" }), - requireTty: false, - expectedError: "timeout", - }, - { - name: "update wizard invalid timeout", - run: async () => await updateWizardCommand({ timeout: "invalid" }), - requireTty: true, - expectedError: "timeout", - }, - { - name: "update wizard requires a TTY", - run: async () => await updateWizardCommand({}), - requireTty: false, - expectedError: "Update wizard requires a TTY", - }, - ] as const; - - for (const testCase of cases) { - setTty(testCase.requireTty); + it.each([ + { + name: "update command invalid timeout", + run: async () => await updateCommand({ timeout: "invalid" }), + requireTty: false, + expectedError: "timeout", + }, + { + name: "update status command invalid timeout", + run: async () => await updateStatusCommand({ timeout: "invalid" }), + requireTty: false, + expectedError: "timeout", + }, + { + name: "update wizard invalid timeout", + run: async () => await updateWizardCommand({ timeout: "invalid" }), + requireTty: true, + expectedError: "timeout", + }, + { + name: "update wizard requires a TTY", + run: async () => await updateWizardCommand({}), + requireTty: false, + expectedError: "Update wizard requires a TTY", + }, + ] as const)( + "validates update command invocation errors: $name", + async ({ run, requireTty, expectedError, name }) => { + setTty(requireTty); vi.mocked(defaultRuntime.error).mockClear(); vi.mocked(defaultRuntime.exit).mockClear(); - await testCase.run(); + await run(); - expect(defaultRuntime.error, testCase.name).toHaveBeenCalledWith( - expect.stringContaining(testCase.expectedError), + expect(defaultRuntime.error, name).toHaveBeenCalledWith( + expect.stringContaining(expectedError), ); - expect(defaultRuntime.exit, testCase.name).toHaveBeenCalledWith(1); - } - }); + expect(defaultRuntime.exit, name).toHaveBeenCalledWith(1); + }, + ); it.each([ { diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index c13a5aa02e7..a9dbc0c7fdd 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -9,30 +9,30 @@ function getChannelConfig(config: unknown, provider: string) { } describe("legacy config detection", () => { - it("rejects legacy routing keys", async () => { - const cases = [ - { - name: "routing.allowFrom", - input: { routing: { allowFrom: ["+15555550123"] } }, - expectedPath: "", - expectedMessage: '"routing"', - }, - { - name: "routing.groupChat.requireMention", - input: { routing: { groupChat: { requireMention: false } } }, - expectedPath: "", - expectedMessage: '"routing"', - }, - ] as const; - for (const testCase of cases) { - const res = validateConfigObject(testCase.input); - expect(res.ok, testCase.name).toBe(false); + it.each([ + { + name: "routing.allowFrom", + input: { routing: { allowFrom: ["+15555550123"] } }, + expectedPath: "", + expectedMessage: '"routing"', + }, + { + name: "routing.groupChat.requireMention", + input: { routing: { groupChat: { requireMention: false } } }, + expectedPath: "", + expectedMessage: '"routing"', + }, + ] as const)( + "rejects legacy routing key: $name", + ({ input, expectedPath, expectedMessage, name }) => { + const res = validateConfigObject(input); + expect(res.ok, name).toBe(false); if (!res.ok) { - expect(res.issues[0]?.path, testCase.name).toBe(testCase.expectedPath); - expect(res.issues[0]?.message, testCase.name).toContain(testCase.expectedMessage); + expect(res.issues[0]?.path, name).toBe(expectedPath); + expect(res.issues[0]?.message, name).toContain(expectedMessage); } - } - }); + }, + ); it("does not rewrite removed routing.allowFrom migrations", async () => { const res = migrateLegacyConfig({ @@ -274,34 +274,28 @@ describe("legacy config detection", () => { expect(validated.config.gateway?.bind).toBe("tailnet"); } }); - it("normalizes gateway.bind host aliases to supported bind modes", async () => { - const cases = [ - { input: "0.0.0.0", expected: "lan" }, - { input: "::", expected: "lan" }, - { input: "127.0.0.1", expected: "loopback" }, - { input: "localhost", expected: "loopback" }, - { input: "::1", expected: "loopback" }, - ] as const; + it.each([ + { input: "0.0.0.0", expected: "lan" }, + { input: "::", expected: "lan" }, + { input: "127.0.0.1", expected: "loopback" }, + { input: "localhost", expected: "loopback" }, + { input: "::1", expected: "loopback" }, + ] as const)("normalizes gateway.bind host alias $input", ({ input, expected }) => { + const res = migrateLegacyConfig({ + gateway: { bind: input }, + }); + expect(res.changes).toContain(`Normalized gateway.bind "${input}" → "${expected}".`); + expect(res.config?.gateway?.bind).toBe(expected); - for (const testCase of cases) { - const res = migrateLegacyConfig({ - gateway: { bind: testCase.input }, - }); - expect(res.changes).toContain( - `Normalized gateway.bind "${testCase.input}" → "${testCase.expected}".`, - ); - expect(res.config?.gateway?.bind).toBe(testCase.expected); - - const validated = validateConfigObject(res.config); - expect(validated.ok).toBe(true); - if (validated.ok) { - expect(validated.config.gateway?.bind).toBe(testCase.expected); - } + const validated = validateConfigObject(res.config); + expect(validated.ok).toBe(true); + if (validated.ok) { + expect(validated.config.gateway?.bind).toBe(expected); } }); - it("flags gateway.bind host aliases as legacy to trigger auto-migration paths", async () => { - const cases = ["0.0.0.0", "::", "127.0.0.1", "localhost", "::1"] as const; - for (const bind of cases) { + it.each(["0.0.0.0", "::", "127.0.0.1", "localhost", "::1"] as const)( + "flags gateway.bind host alias as legacy: %s", + (bind) => { const validated = validateConfigObject({ gateway: { bind } }); expect(validated.ok, bind).toBe(false); if (!validated.ok) { @@ -310,53 +304,53 @@ describe("legacy config detection", () => { bind, ).toBe(true); } - } - }); + }, + ); it("escapes control characters in gateway.bind migration change text", async () => { const res = migrateLegacyConfig({ gateway: { bind: "\r\n0.0.0.0\r\n" }, }); expect(res.changes).toContain('Normalized gateway.bind "\\r\\n0.0.0.0\\r\\n" → "lan".'); }); - it('enforces dmPolicy="open" allowFrom wildcard for supported providers', async () => { - const cases = [ - { - provider: "telegram", - allowFrom: ["123456789"], - expectedIssuePath: "channels.telegram.allowFrom", - }, - { - provider: "whatsapp", - allowFrom: ["+15555550123"], - expectedIssuePath: "channels.whatsapp.allowFrom", - }, - { - provider: "signal", - allowFrom: ["+15555550123"], - expectedIssuePath: "channels.signal.allowFrom", - }, - { - provider: "imessage", - allowFrom: ["+15555550123"], - expectedIssuePath: "channels.imessage.allowFrom", - }, - ] as const; - for (const testCase of cases) { + it.each([ + { + provider: "telegram", + allowFrom: ["123456789"], + expectedIssuePath: "channels.telegram.allowFrom", + }, + { + provider: "whatsapp", + allowFrom: ["+15555550123"], + expectedIssuePath: "channels.whatsapp.allowFrom", + }, + { + provider: "signal", + allowFrom: ["+15555550123"], + expectedIssuePath: "channels.signal.allowFrom", + }, + { + provider: "imessage", + allowFrom: ["+15555550123"], + expectedIssuePath: "channels.imessage.allowFrom", + }, + ] as const)( + 'enforces dmPolicy="open" allowFrom wildcard for $provider', + ({ provider, allowFrom, expectedIssuePath }) => { const res = validateConfigObject({ channels: { - [testCase.provider]: { dmPolicy: "open", allowFrom: testCase.allowFrom }, + [provider]: { dmPolicy: "open", allowFrom }, }, }); - expect(res.ok, testCase.provider).toBe(false); + expect(res.ok, provider).toBe(false); if (!res.ok) { - expect(res.issues[0]?.path, testCase.provider).toBe(testCase.expectedIssuePath); + expect(res.issues[0]?.path, provider).toBe(expectedIssuePath); } - } - }); + }, + ); - it('accepts dmPolicy="open" when allowFrom includes wildcard', async () => { - const providers = ["telegram", "whatsapp", "signal"] as const; - for (const provider of providers) { + it.each(["telegram", "whatsapp", "signal"] as const)( + 'accepts dmPolicy="open" with wildcard for %s', + (provider) => { const res = validateConfigObject({ channels: { [provider]: { dmPolicy: "open", allowFrom: ["*"] } }, }); @@ -365,12 +359,12 @@ describe("legacy config detection", () => { const channel = getChannelConfig(res.config, provider); expect(channel?.dmPolicy, provider).toBe("open"); } - } - }); + }, + ); - it("defaults dm/group policy for configured providers", async () => { - const providers = ["telegram", "whatsapp", "signal"] as const; - for (const provider of providers) { + it.each(["telegram", "whatsapp", "signal"] as const)( + "defaults dm/group policy for configured provider %s", + (provider) => { const res = validateConfigObject({ channels: { [provider]: {} } }); expect(res.ok, provider).toBe(true); if (res.ok) { @@ -382,179 +376,164 @@ describe("legacy config detection", () => { expect(channel?.streamMode, provider).toBeUndefined(); } } - } - }); - it("normalizes telegram legacy streamMode aliases", async () => { - const cases = [ - { - name: "top-level off", - input: { channels: { telegram: { streamMode: "off" } } }, - expectedTopLevel: "off", + }, + ); + it.each([ + { + name: "top-level off", + input: { channels: { telegram: { streamMode: "off" } } }, + assert: (config: NonNullable) => { + expect(config.channels?.telegram?.streaming).toBe("off"); + expect(config.channels?.telegram?.streamMode).toBeUndefined(); }, - { - name: "top-level block", - input: { channels: { telegram: { streamMode: "block" } } }, - expectedTopLevel: "block", + }, + { + name: "top-level block", + input: { channels: { telegram: { streamMode: "block" } } }, + assert: (config: NonNullable) => { + expect(config.channels?.telegram?.streaming).toBe("block"); + expect(config.channels?.telegram?.streamMode).toBeUndefined(); }, - { - name: "per-account off", - input: { - channels: { - telegram: { - accounts: { - ops: { - streamMode: "off", - }, + }, + { + name: "per-account off", + input: { + channels: { + telegram: { + accounts: { + ops: { + streamMode: "off", }, }, }, }, - expectedAccountStreaming: "off", }, - ] as const; - for (const testCase of cases) { - const res = validateConfigObject(testCase.input); - expect(res.ok, testCase.name).toBe(true); - if (res.ok) { - if ("expectedTopLevel" in testCase && testCase.expectedTopLevel !== undefined) { - expect(res.config.channels?.telegram?.streaming, testCase.name).toBe( - testCase.expectedTopLevel, - ); - expect(res.config.channels?.telegram?.streamMode, testCase.name).toBeUndefined(); - } - if ( - "expectedAccountStreaming" in testCase && - testCase.expectedAccountStreaming !== undefined - ) { - expect(res.config.channels?.telegram?.accounts?.ops?.streaming, testCase.name).toBe( - testCase.expectedAccountStreaming, - ); - expect( - res.config.channels?.telegram?.accounts?.ops?.streamMode, - testCase.name, - ).toBeUndefined(); - } - } + assert: (config: NonNullable) => { + expect(config.channels?.telegram?.accounts?.ops?.streaming).toBe("off"); + expect(config.channels?.telegram?.accounts?.ops?.streamMode).toBeUndefined(); + }, + }, + ] as const)("normalizes telegram legacy streamMode alias: $name", ({ input, assert, name }) => { + const res = validateConfigObject(input); + expect(res.ok, name).toBe(true); + if (res.ok) { + assert(res.config); } }); - it("normalizes discord streaming fields during legacy migration", async () => { - const cases = [ - { - name: "boolean streaming=true", - input: { channels: { discord: { streaming: true } } }, - expectedChanges: ["Normalized channels.discord.streaming boolean → enum (partial)."], - expectedStreaming: "partial", - }, - { - name: "streamMode with streaming boolean", - input: { channels: { discord: { streaming: false, streamMode: "block" } } }, - expectedChanges: [ - "Moved channels.discord.streamMode → channels.discord.streaming (block).", - "Normalized channels.discord.streaming boolean → enum (block).", - ], - expectedStreaming: "block", - }, - ] as const; - for (const testCase of cases) { - const res = migrateLegacyConfig(testCase.input); - for (const expectedChange of testCase.expectedChanges) { - expect(res.changes, testCase.name).toContain(expectedChange); + it.each([ + { + name: "boolean streaming=true", + input: { channels: { discord: { streaming: true } } }, + expectedChanges: ["Normalized channels.discord.streaming boolean → enum (partial)."], + expectedStreaming: "partial", + }, + { + name: "streamMode with streaming boolean", + input: { channels: { discord: { streaming: false, streamMode: "block" } } }, + expectedChanges: [ + "Moved channels.discord.streamMode → channels.discord.streaming (block).", + "Normalized channels.discord.streaming boolean → enum (block).", + ], + expectedStreaming: "block", + }, + ] as const)( + "normalizes discord streaming fields during legacy migration: $name", + ({ input, expectedChanges, expectedStreaming, name }) => { + const res = migrateLegacyConfig(input); + for (const expectedChange of expectedChanges) { + expect(res.changes, name).toContain(expectedChange); } - expect(res.config?.channels?.discord?.streaming, testCase.name).toBe( - testCase.expectedStreaming, - ); - expect(res.config?.channels?.discord?.streamMode, testCase.name).toBeUndefined(); - } - }); + expect(res.config?.channels?.discord?.streaming, name).toBe(expectedStreaming); + expect(res.config?.channels?.discord?.streamMode, name).toBeUndefined(); + }, + ); - it("normalizes discord streaming fields during validation", async () => { - const cases = [ - { - name: "streaming=true", - input: { channels: { discord: { streaming: true } } }, - expectedStreaming: "partial", - }, - { - name: "streaming=false", - input: { channels: { discord: { streaming: false } } }, - expectedStreaming: "off", - }, - { - name: "streamMode overrides streaming boolean", - input: { channels: { discord: { streamMode: "block", streaming: false } } }, - expectedStreaming: "block", - }, - ] as const; - for (const testCase of cases) { - const res = validateConfigObject(testCase.input); - expect(res.ok, testCase.name).toBe(true); + it.each([ + { + name: "streaming=true", + input: { channels: { discord: { streaming: true } } }, + expectedStreaming: "partial", + }, + { + name: "streaming=false", + input: { channels: { discord: { streaming: false } } }, + expectedStreaming: "off", + }, + { + name: "streamMode overrides streaming boolean", + input: { channels: { discord: { streamMode: "block", streaming: false } } }, + expectedStreaming: "block", + }, + ] as const)( + "normalizes discord streaming fields during validation: $name", + ({ input, expectedStreaming, name }) => { + const res = validateConfigObject(input); + expect(res.ok, name).toBe(true); if (res.ok) { - expect(res.config.channels?.discord?.streaming, testCase.name).toBe( - testCase.expectedStreaming, - ); - expect(res.config.channels?.discord?.streamMode, testCase.name).toBeUndefined(); + expect(res.config.channels?.discord?.streaming, name).toBe(expectedStreaming); + expect(res.config.channels?.discord?.streamMode, name).toBeUndefined(); } - } - }); - it("normalizes account-level discord and slack streaming aliases", async () => { - const cases = [ - { - name: "discord account streaming boolean", - input: { - channels: { - discord: { - accounts: { - work: { - streaming: true, - }, + }, + ); + it.each([ + { + name: "discord account streaming boolean", + input: { + channels: { + discord: { + accounts: { + work: { + streaming: true, }, }, }, }, - assert: (config: NonNullable) => { - expect(config.channels?.discord?.accounts?.work?.streaming).toBe("partial"); - expect(config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined(); - }, }, - { - name: "slack streamMode alias", - input: { - channels: { - slack: { - streamMode: "status_final", - }, + assert: (config: NonNullable) => { + expect(config.channels?.discord?.accounts?.work?.streaming).toBe("partial"); + expect(config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined(); + }, + }, + { + name: "slack streamMode alias", + input: { + channels: { + slack: { + streamMode: "status_final", }, }, - assert: (config: NonNullable) => { - expect(config.channels?.slack?.streaming).toBe("progress"); - expect(config.channels?.slack?.streamMode).toBeUndefined(); - expect(config.channels?.slack?.nativeStreaming).toBe(true); - }, }, - { - name: "slack streaming boolean legacy", - input: { - channels: { - slack: { - streaming: false, - }, + assert: (config: NonNullable) => { + expect(config.channels?.slack?.streaming).toBe("progress"); + expect(config.channels?.slack?.streamMode).toBeUndefined(); + expect(config.channels?.slack?.nativeStreaming).toBe(true); + }, + }, + { + name: "slack streaming boolean legacy", + input: { + channels: { + slack: { + streaming: false, }, }, - assert: (config: NonNullable) => { - expect(config.channels?.slack?.streaming).toBe("off"); - expect(config.channels?.slack?.nativeStreaming).toBe(false); - }, }, - ] as const; - for (const testCase of cases) { - const res = validateConfigObject(testCase.input); - expect(res.ok, testCase.name).toBe(true); + assert: (config: NonNullable) => { + expect(config.channels?.slack?.streaming).toBe("off"); + expect(config.channels?.slack?.nativeStreaming).toBe(false); + }, + }, + ] as const)( + "normalizes account-level discord/slack streaming alias: $name", + ({ input, assert, name }) => { + const res = validateConfigObject(input); + expect(res.ok, name).toBe(true); if (res.ok) { - testCase.assert(res.config); + assert(res.config); } - } - }); + }, + ); it("accepts historyLimit overrides per provider and account", async () => { const res = validateConfigObject({ messages: { groupChat: { historyLimit: 12 } }, diff --git a/src/config/includes.test.ts b/src/config/includes.test.ts index 71ebb3e3870..fa152f41b3e 100644 --- a/src/config/includes.test.ts +++ b/src/config/includes.test.ts @@ -64,22 +64,19 @@ function expectResolveIncludeError( } describe("resolveConfigIncludes", () => { - it("passes through non-include values unchanged", () => { - const cases = [ - { value: "hello", expected: "hello" }, - { value: 42, expected: 42 }, - { value: true, expected: true }, - { value: null, expected: null }, - { value: [1, 2, { a: 1 }], expected: [1, 2, { a: 1 }] }, - { - value: { foo: "bar", nested: { x: 1 } }, - expected: { foo: "bar", nested: { x: 1 } }, - }, - ] as const; - - for (const { value, expected } of cases) { - expect(resolve(value)).toEqual(expected); - } + it.each([ + { name: "string", value: "hello", expected: "hello" }, + { name: "number", value: 42, expected: 42 }, + { name: "boolean", value: true, expected: true }, + { name: "null", value: null, expected: null }, + { name: "array", value: [1, 2, { a: 1 }], expected: [1, 2, { a: 1 }] }, + { + name: "nested object", + value: { foo: "bar", nested: { x: 1 } }, + expected: { foo: "bar", nested: { x: 1 } }, + }, + ] as const)("passes through non-include $name values unchanged", ({ value, expected }) => { + expect(resolve(value)).toEqual(expected); }); it("rejects absolute path outside config directory (CWE-22)", () => { @@ -89,82 +86,77 @@ describe("resolveConfigIncludes", () => { expectResolveIncludeError(() => resolve(obj, files), /escapes config directory/); }); - it("resolves single and array include merges", () => { - const cases = [ - { - name: "single file include", - files: { [configPath("agents.json")]: { list: [{ id: "main" }] } }, - obj: { agents: { $include: "./agents.json" } }, - expected: { - agents: { list: [{ id: "main" }] }, + it.each([ + { + name: "single file include", + files: { [configPath("agents.json")]: { list: [{ id: "main" }] } }, + obj: { agents: { $include: "./agents.json" } }, + expected: { + agents: { list: [{ id: "main" }] }, + }, + }, + { + name: "array include deep merge", + files: { + [configPath("a.json")]: { "group-a": ["agent1"] }, + [configPath("b.json")]: { "group-b": ["agent2"] }, + }, + obj: { broadcast: { $include: ["./a.json", "./b.json"] } }, + expected: { + broadcast: { + "group-a": ["agent1"], + "group-b": ["agent2"], }, }, - { - name: "array include deep merge", - files: { - [configPath("a.json")]: { "group-a": ["agent1"] }, - [configPath("b.json")]: { "group-b": ["agent2"] }, - }, - obj: { broadcast: { $include: ["./a.json", "./b.json"] } }, - expected: { - broadcast: { - "group-a": ["agent1"], - "group-b": ["agent2"], - }, + }, + { + name: "array include overlapping keys", + files: { + [configPath("a.json")]: { agents: { defaults: { workspace: "~/a" } } }, + [configPath("b.json")]: { agents: { list: [{ id: "main" }] } }, + }, + obj: { $include: ["./a.json", "./b.json"] }, + expected: { + agents: { + defaults: { workspace: "~/a" }, + list: [{ id: "main" }], }, }, - { - name: "array include overlapping keys", - files: { - [configPath("a.json")]: { agents: { defaults: { workspace: "~/a" } } }, - [configPath("b.json")]: { agents: { list: [{ id: "main" }] } }, - }, - obj: { $include: ["./a.json", "./b.json"] }, - expected: { - agents: { - defaults: { workspace: "~/a" }, - list: [{ id: "main" }], - }, - }, - }, - ] as const; - - for (const testCase of cases) { - expect(resolve(testCase.obj, testCase.files), testCase.name).toEqual(testCase.expected); - } + }, + ] as const)("resolves include merges: $name", ({ obj, files, expected }) => { + expect(resolve(obj, files)).toEqual(expected); }); - it("merges include content with sibling keys and sibling overrides", () => { + it.each([ + { + name: "adds sibling keys after include", + obj: { $include: "./base.json", c: 3 }, + expected: { a: 1, b: 2, c: 3 }, + }, + { + name: "lets siblings override included keys", + obj: { $include: "./base.json", b: 99 }, + expected: { a: 1, b: 99 }, + }, + ] as const)("merges include content with sibling keys: $name", ({ obj, expected }) => { const files = { [configPath("base.json")]: { a: 1, b: 2 } }; - const cases = [ - { - obj: { $include: "./base.json", c: 3 }, - expected: { a: 1, b: 2, c: 3 }, - }, - { - obj: { $include: "./base.json", b: 99 }, - expected: { a: 1, b: 99 }, - }, - ] as const; - for (const testCase of cases) { - expect(resolve(testCase.obj, files)).toEqual(testCase.expected); - } + expect(resolve(obj, files)).toEqual(expected); }); - it("throws when sibling keys are used with non-object includes", () => { - const cases = [ - { includeFile: "list.json", included: ["a", "b"] }, - { includeFile: "value.json", included: "hello" }, - ] as const; - for (const testCase of cases) { - const files = { [configPath(testCase.includeFile)]: testCase.included }; - const obj = { $include: `./${testCase.includeFile}`, extra: true }; + it.each([ + { includeFile: "list.json", included: ["a", "b"] }, + { includeFile: "value.json", included: "hello" }, + ] as const)( + "throws when sibling keys are used with non-object include $includeFile", + ({ includeFile, included }) => { + const files = { [configPath(includeFile)]: included }; + const obj = { $include: `./${includeFile}`, extra: true }; expectResolveIncludeError( () => resolve(obj, files), /Sibling keys require included content to be an object/, ); - } - }); + }, + ); it("resolves nested includes", () => { const files = { @@ -177,25 +169,23 @@ describe("resolveConfigIncludes", () => { }); }); - it("surfaces include read and parse failures", () => { - const cases = [ - { - run: () => resolve({ $include: "./missing.json" }), - pattern: /Failed to read include file/, - }, - { - run: () => - resolveConfigIncludes({ $include: "./bad.json" }, DEFAULT_BASE_PATH, { - readFile: () => "{ invalid json }", - parseJson: JSON.parse, - }), - pattern: /Failed to parse include file/, - }, - ] as const; - - for (const testCase of cases) { - expectResolveIncludeError(testCase.run, testCase.pattern); - } + it.each([ + { + name: "read failures", + run: () => resolve({ $include: "./missing.json" }), + pattern: /Failed to read include file/, + }, + { + name: "parse failures", + run: () => + resolveConfigIncludes({ $include: "./bad.json" }, DEFAULT_BASE_PATH, { + readFile: () => "{ invalid json }", + parseJson: JSON.parse, + }), + pattern: /Failed to parse include file/, + }, + ] as const)("surfaces include $name", ({ run, pattern }) => { + expectResolveIncludeError(run, pattern); }); it("throws CircularIncludeError for circular includes", () => { @@ -227,30 +217,30 @@ describe("resolveConfigIncludes", () => { } }); - it("throws on invalid include value/item types", () => { + it.each([ + { + name: "rejects scalar include value", + obj: { $include: 123 }, + expectedPattern: /expected string or array/, + }, + { + name: "rejects number in include array", + obj: { $include: ["./valid.json", 123] }, + expectedPattern: /expected string, got number/, + }, + { + name: "rejects null in include array", + obj: { $include: ["./valid.json", null] }, + expectedPattern: /expected string, got object/, + }, + { + name: "rejects boolean in include array", + obj: { $include: ["./valid.json", false] }, + expectedPattern: /expected string, got boolean/, + }, + ] as const)("throws on invalid include value/item types: $name", ({ obj, expectedPattern }) => { const files = { [configPath("valid.json")]: { valid: true } }; - const cases = [ - { - obj: { $include: 123 }, - expectedPattern: /expected string or array/, - }, - { - obj: { $include: ["./valid.json", 123] }, - expectedPattern: /expected string, got number/, - }, - { - obj: { $include: ["./valid.json", null] }, - expectedPattern: /expected string, got object/, - }, - { - obj: { $include: ["./valid.json", false] }, - expectedPattern: /expected string, got boolean/, - }, - ] as const; - - for (const testCase of cases) { - expectResolveIncludeError(() => resolve(testCase.obj, files), testCase.expectedPattern); - } + expectResolveIncludeError(() => resolve(obj, files), expectedPattern); }); it("respects max depth limit", () => { @@ -289,32 +279,34 @@ describe("resolveConfigIncludes", () => { ); }); - it("handles relative paths and nested-include override ordering", () => { - const cases = [ - { - files: { - [configPath("clients", "mueller", "agents.json")]: { id: "mueller" }, - }, - obj: { agent: { $include: "./clients/mueller/agents.json" } }, - expected: { - agent: { id: "mueller" }, - }, + it.each([ + { + name: "resolves nested relative file path", + files: { + [configPath("clients", "mueller", "agents.json")]: { id: "mueller" }, }, - { - files: { - [configPath("base.json")]: { nested: { $include: "./nested.json" } }, - [configPath("nested.json")]: { a: 1, b: 2 }, - }, - obj: { $include: "./base.json", nested: { b: 9 } }, - expected: { - nested: { a: 1, b: 9 }, - }, + obj: { agent: { $include: "./clients/mueller/agents.json" } }, + expected: { + agent: { id: "mueller" }, }, - ] as const; - for (const testCase of cases) { - expect(resolve(testCase.obj, testCase.files)).toEqual(testCase.expected); - } - }); + }, + { + name: "preserves nested override ordering", + files: { + [configPath("base.json")]: { nested: { $include: "./nested.json" } }, + [configPath("nested.json")]: { a: 1, b: 2 }, + }, + obj: { $include: "./base.json", nested: { b: 9 } }, + expected: { + nested: { a: 1, b: 9 }, + }, + }, + ] as const)( + "handles relative paths and nested include ordering: $name", + ({ obj, files, expected }) => { + expect(resolve(obj, files)).toEqual(expected); + }, + ); it("enforces traversal boundaries while allowing safe nested-parent paths", () => { expectResolveIncludeError( @@ -342,91 +334,87 @@ describe("resolveConfigIncludes", () => { }); describe("real-world config patterns", () => { - it("supports common modular include layouts", () => { - const cases = [ - { - name: "per-client agent includes", - files: { - [configPath("clients", "mueller.json")]: { - agents: [ - { - id: "mueller-screenshot", - workspace: "~/clients/mueller/screenshot", - }, - { - id: "mueller-transcribe", - workspace: "~/clients/mueller/transcribe", - }, - ], - broadcast: { - "group-mueller": ["mueller-screenshot", "mueller-transcribe"], - }, - }, - [configPath("clients", "schmidt.json")]: { - agents: [ - { - id: "schmidt-screenshot", - workspace: "~/clients/schmidt/screenshot", - }, - ], - broadcast: { "group-schmidt": ["schmidt-screenshot"] }, - }, - }, - obj: { - gateway: { port: 18789 }, - $include: ["./clients/mueller.json", "./clients/schmidt.json"], - }, - expected: { - gateway: { port: 18789 }, + it.each([ + { + name: "per-client agent includes", + files: { + [configPath("clients", "mueller.json")]: { agents: [ - { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" }, - { id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe" }, - { id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot" }, + { + id: "mueller-screenshot", + workspace: "~/clients/mueller/screenshot", + }, + { + id: "mueller-transcribe", + workspace: "~/clients/mueller/transcribe", + }, ], broadcast: { "group-mueller": ["mueller-screenshot", "mueller-transcribe"], - "group-schmidt": ["schmidt-screenshot"], }, }, + [configPath("clients", "schmidt.json")]: { + agents: [ + { + id: "schmidt-screenshot", + workspace: "~/clients/schmidt/screenshot", + }, + ], + broadcast: { "group-schmidt": ["schmidt-screenshot"] }, + }, }, - { - name: "modular config structure", - files: { - [configPath("gateway.json")]: { - gateway: { port: 18789, bind: "loopback" }, - }, - [configPath("channels", "whatsapp.json")]: { - channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] } }, - }, - [configPath("agents", "defaults.json")]: { - agents: { defaults: { sandbox: { mode: "all" } } }, - }, + obj: { + gateway: { port: 18789 }, + $include: ["./clients/mueller.json", "./clients/schmidt.json"], + }, + expected: { + gateway: { port: 18789 }, + agents: [ + { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" }, + { id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe" }, + { id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot" }, + ], + broadcast: { + "group-mueller": ["mueller-screenshot", "mueller-transcribe"], + "group-schmidt": ["schmidt-screenshot"], }, - obj: { - $include: ["./gateway.json", "./channels/whatsapp.json", "./agents/defaults.json"], - }, - expected: { + }, + }, + { + name: "modular config structure", + files: { + [configPath("gateway.json")]: { gateway: { port: 18789, bind: "loopback" }, + }, + [configPath("channels", "whatsapp.json")]: { channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] } }, + }, + [configPath("agents", "defaults.json")]: { agents: { defaults: { sandbox: { mode: "all" } } }, }, }, - ] as const; - - for (const testCase of cases) { - expect(resolve(testCase.obj, testCase.files), testCase.name).toEqual(testCase.expected); - } + obj: { + $include: ["./gateway.json", "./channels/whatsapp.json", "./agents/defaults.json"], + }, + expected: { + gateway: { port: 18789, bind: "loopback" }, + channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] } }, + agents: { defaults: { sandbox: { mode: "all" } } }, + }, + }, + ] as const)("supports common modular include layouts: $name", ({ obj, files, expected }) => { + expect(resolve(obj, files)).toEqual(expected); }); }); describe("security: path traversal protection (CWE-22)", () => { function expectRejectedTraversalPaths( cases: ReadonlyArray<{ includePath: string; expectEscapesMessage: boolean }>, ) { - for (const testCase of cases) { - const obj = { $include: testCase.includePath }; - expect(() => resolve(obj, {}), testCase.includePath).toThrow(ConfigIncludeError); - if (testCase.expectEscapesMessage) { - expect(() => resolve(obj, {}), testCase.includePath).toThrow(/escapes config directory/); + for (const { includePath, expectEscapesMessage } of cases) { + const obj = { $include: includePath }; + expect(() => resolve(obj, {}), includePath).toThrow(ConfigIncludeError); + if (expectEscapesMessage) { + expect(() => resolve(obj, {}), includePath).toThrow(/escapes config directory/); } } } @@ -458,39 +446,38 @@ describe("security: path traversal protection (CWE-22)", () => { }); describe("legitimate includes (should work)", () => { - it("allows legitimate include paths under config root", () => { - const cases = [ - { - name: "same-directory with ./ prefix", - includePath: "./sub.json", - files: { [configPath("sub.json")]: { key: "value" } }, - expected: { key: "value" }, - }, - { - name: "same-directory without ./ prefix", - includePath: "sub.json", - files: { [configPath("sub.json")]: { key: "value" } }, - expected: { key: "value" }, - }, - { - name: "subdirectory", - includePath: "./sub/nested.json", - files: { [configPath("sub", "nested.json")]: { nested: true } }, - expected: { nested: true }, - }, - { - name: "deep subdirectory", - includePath: "./a/b/c/deep.json", - files: { [configPath("a", "b", "c", "deep.json")]: { deep: true } }, - expected: { deep: true }, - }, - ] as const; - - for (const testCase of cases) { - const obj = { $include: testCase.includePath }; - expect(resolve(obj, testCase.files), testCase.name).toEqual(testCase.expected); - } - }); + it.each([ + { + name: "same-directory with ./ prefix", + includePath: "./sub.json", + files: { [configPath("sub.json")]: { key: "value" } }, + expected: { key: "value" }, + }, + { + name: "same-directory without ./ prefix", + includePath: "sub.json", + files: { [configPath("sub.json")]: { key: "value" } }, + expected: { key: "value" }, + }, + { + name: "subdirectory", + includePath: "./sub/nested.json", + files: { [configPath("sub", "nested.json")]: { nested: true } }, + expected: { nested: true }, + }, + { + name: "deep subdirectory", + includePath: "./a/b/c/deep.json", + files: { [configPath("a", "b", "c", "deep.json")]: { deep: true } }, + expected: { deep: true }, + }, + ] as const)( + "allows legitimate include path under config root: $name", + ({ includePath, files, expected }) => { + const obj = { $include: includePath }; + expect(resolve(obj, files)).toEqual(expected); + }, + ); // Note: Upward traversal from nested configs is restricted for security. // Each config file can only include files from its own directory and subdirectories. @@ -498,62 +485,53 @@ describe("security: path traversal protection (CWE-22)", () => { }); describe("error properties", () => { - it("preserves error type/path/message details", () => { - const cases = [ - { - includePath: "/etc/passwd", - expectedMessageIncludes: ["escapes config directory", "/etc/passwd"], - }, - { - includePath: "/etc/shadow", - expectedMessageIncludes: ["/etc/shadow"], - }, - { - includePath: "../../etc/passwd", - expectedMessageIncludes: ["escapes config directory", "../../etc/passwd"], - }, - ] as const; - - for (const testCase of cases) { - const obj = { $include: testCase.includePath }; + it.each([ + { + includePath: "/etc/passwd", + expectedMessageIncludes: ["escapes config directory", "/etc/passwd"], + }, + { + includePath: "/etc/shadow", + expectedMessageIncludes: ["/etc/shadow"], + }, + { + includePath: "../../etc/passwd", + expectedMessageIncludes: ["escapes config directory", "../../etc/passwd"], + }, + ] as const)( + "preserves error type/path/message details for $includePath", + ({ includePath, expectedMessageIncludes }) => { + const obj = { $include: includePath }; try { resolve(obj, {}); expect.fail("Should have thrown"); } catch (err) { - expect(err, testCase.includePath).toBeInstanceOf(ConfigIncludeError); - expect(err, testCase.includePath).toHaveProperty("name", "ConfigIncludeError"); - expect((err as ConfigIncludeError).includePath, testCase.includePath).toBe( - testCase.includePath, - ); - for (const messagePart of testCase.expectedMessageIncludes) { - expect((err as Error).message, `${testCase.includePath}: ${messagePart}`).toContain( - messagePart, - ); + expect(err, includePath).toBeInstanceOf(ConfigIncludeError); + expect(err, includePath).toHaveProperty("name", "ConfigIncludeError"); + expect((err as ConfigIncludeError).includePath, includePath).toBe(includePath); + for (const messagePart of expectedMessageIncludes) { + expect((err as Error).message, `${includePath}: ${messagePart}`).toContain(messagePart); } } - } - }); + }, + ); }); describe("array includes with malicious paths", () => { - it("rejects arrays that contain malicious include paths", () => { - const cases = [ - { - name: "one malicious path", - files: { [configPath("good.json")]: { good: true } }, - includePaths: ["./good.json", "/etc/passwd"], - }, - { - name: "multiple malicious paths", - files: {}, - includePaths: ["/etc/passwd", "/etc/shadow"], - }, - ] as const; - - for (const testCase of cases) { - const obj = { $include: testCase.includePaths }; - expect(() => resolve(obj, testCase.files), testCase.name).toThrow(ConfigIncludeError); - } + it.each([ + { + name: "one malicious path", + files: { [configPath("good.json")]: { good: true } }, + includePaths: ["./good.json", "/etc/passwd"], + }, + { + name: "multiple malicious paths", + files: {}, + includePaths: ["/etc/passwd", "/etc/shadow"], + }, + ] as const)("rejects arrays with malicious include paths: $name", ({ includePaths, files }) => { + const obj = { $include: includePaths }; + expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); }); it("allows array with all legitimate paths", () => { @@ -586,29 +564,26 @@ describe("security: path traversal protection (CWE-22)", () => { }, ] as const; - for (const testCase of cases) { - const result = deepMerge(testCase.base, testCase.incoming); + for (const { base, incoming, expected } of cases) { + const result = deepMerge(base, incoming); expect((Object.prototype as Record).polluted).toBeUndefined(); - expect(result).toEqual(testCase.expected); + expect(result).toEqual(expected); } }); }); describe("edge cases", () => { - it("rejects malformed include paths", () => { - const cases = [ - { includePath: "./file\x00.json", expectedError: undefined }, - { includePath: "//etc/passwd", expectedError: ConfigIncludeError }, - ] as const; - for (const testCase of cases) { - const obj = { $include: testCase.includePath }; - if (testCase.expectedError) { - expectResolveIncludeError(() => resolve(obj, {})); - continue; - } - // Path with null byte should be rejected or handled safely. - expect(() => resolve(obj, {}), testCase.includePath).toThrow(); + it.each([ + { includePath: "./file\x00.json", expectedError: undefined }, + { includePath: "//etc/passwd", expectedError: ConfigIncludeError }, + ] as const)("rejects malformed include path $includePath", ({ includePath, expectedError }) => { + const obj = { $include: includePath }; + if (expectedError) { + expectResolveIncludeError(() => resolve(obj, {})); + return; } + // Path with null byte should be rejected or handled safely. + expect(() => resolve(obj, {}), includePath).toThrow(); }); it("allows child include when config is at filesystem root", () => { diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 559089571e6..93c7fe715d2 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -402,45 +402,41 @@ describe("redactConfigSnapshot", () => { expect(env.vars.OPENAI_API_KEY).toBe(REDACTED_SENTINEL); }); - it("respects token-name redaction boundaries", () => { - const cases = [ - { - name: "does not redact numeric tokens field", - snapshot: makeSnapshot({ memory: { tokens: 8192 } }), - assert: (config: Record) => { - expect((config.memory as Record).tokens).toBe(8192); - }, + it.each([ + { + name: "does not redact numeric tokens field", + snapshot: makeSnapshot({ memory: { tokens: 8192 } }), + assert: (config: Record) => { + expect((config.memory as Record).tokens).toBe(8192); }, - { - name: "does not redact softThresholdTokens", - snapshot: makeSnapshot({ compaction: { softThresholdTokens: 50000 } }), - assert: (config: Record) => { - expect((config.compaction as Record).softThresholdTokens).toBe(50000); - }, + }, + { + name: "does not redact softThresholdTokens", + snapshot: makeSnapshot({ compaction: { softThresholdTokens: 50000 } }), + assert: (config: Record) => { + expect((config.compaction as Record).softThresholdTokens).toBe(50000); }, - { - name: "does not redact string tokens field", - snapshot: makeSnapshot({ memory: { tokens: "should-not-be-redacted" } }), - assert: (config: Record) => { - expect((config.memory as Record).tokens).toBe("should-not-be-redacted"); - }, + }, + { + name: "does not redact string tokens field", + snapshot: makeSnapshot({ memory: { tokens: "should-not-be-redacted" } }), + assert: (config: Record) => { + expect((config.memory as Record).tokens).toBe("should-not-be-redacted"); }, - { - name: "still redacts singular token field", - snapshot: makeSnapshot({ - channels: { slack: { token: "secret-slack-token-value-here" } }, - }), - assert: (config: Record) => { - const channels = config.channels as Record>; - expect(channels.slack.token).toBe(REDACTED_SENTINEL); - }, + }, + { + name: "still redacts singular token field", + snapshot: makeSnapshot({ + channels: { slack: { token: "secret-slack-token-value-here" } }, + }), + assert: (config: Record) => { + const channels = config.channels as Record>; + expect(channels.slack.token).toBe(REDACTED_SENTINEL); }, - ] as const; - - for (const testCase of cases) { - const result = redactConfigSnapshot(testCase.snapshot); - testCase.assert(result.config as Record); - } + }, + ] as const)("respects token-name redaction boundaries: $name", ({ snapshot, assert }) => { + const result = redactConfigSnapshot(snapshot); + assert(result.config as Record); }); it("uses uiHints to determine sensitivity", () => { @@ -734,14 +730,10 @@ describe("redactConfigSnapshot", () => { }, ]; - for (const testCase of cases) { - const redacted = redactConfigSnapshot(testCase.snapshot, testCase.hints); - const restored = restoreRedactedValues( - redacted.config, - testCase.snapshot.config, - testCase.hints, - ); - testCase.assert({ + for (const { snapshot, hints, assert } of cases) { + const redacted = redactConfigSnapshot(snapshot, hints); + const restored = restoreRedactedValues(redacted.config, snapshot.config, hints); + assert({ redacted: redacted.config as Record, restored: restored as Record, }); diff --git a/src/hooks/internal-hooks.test.ts b/src/hooks/internal-hooks.test.ts index 74eab42cd58..1dda5a520b0 100644 --- a/src/hooks/internal-hooks.test.ts +++ b/src/hooks/internal-hooks.test.ts @@ -188,11 +188,7 @@ describe("hooks", () => { }); describe("isAgentBootstrapEvent", () => { - const cases: Array<{ - name: string; - event: ReturnType; - expected: boolean; - }> = [ + it.each([ { name: "returns true for agent:bootstrap events with expected context", event: createInternalHookEvent("agent", "bootstrap", "test-session", { @@ -206,21 +202,17 @@ describe("hooks", () => { event: createInternalHookEvent("command", "new", "test-session"), expected: false, }, - ]; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(isAgentBootstrapEvent(testCase.event)).toBe(testCase.expected); - }); - } - }); - - describe("isGatewayStartupEvent", () => { - const cases: Array<{ + ] satisfies Array<{ name: string; event: ReturnType; expected: boolean; - }> = [ + }>)("$name", ({ event, expected }) => { + expect(isAgentBootstrapEvent(event)).toBe(expected); + }); + }); + + describe("isGatewayStartupEvent", () => { + it.each([ { name: "returns true for gateway:startup events with expected context", event: createInternalHookEvent("gateway", "startup", "gateway:startup", { @@ -233,21 +225,17 @@ describe("hooks", () => { event: createInternalHookEvent("gateway", "shutdown", "gateway:shutdown", {}), expected: false, }, - ]; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(isGatewayStartupEvent(testCase.event)).toBe(testCase.expected); - }); - } - }); - - describe("isMessageReceivedEvent", () => { - const cases: Array<{ + ] satisfies Array<{ name: string; event: ReturnType; expected: boolean; - }> = [ + }>)("$name", ({ event, expected }) => { + expect(isGatewayStartupEvent(event)).toBe(expected); + }); + }); + + describe("isMessageReceivedEvent", () => { + it.each([ { name: "returns true for message:received events with expected context", event: createInternalHookEvent("message", "received", "test-session", { @@ -269,21 +257,17 @@ describe("hooks", () => { } satisfies MessageSentHookContext), expected: false, }, - ]; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(isMessageReceivedEvent(testCase.event)).toBe(testCase.expected); - }); - } - }); - - describe("isMessageSentEvent", () => { - const cases: Array<{ + ] satisfies Array<{ name: string; event: ReturnType; expected: boolean; - }> = [ + }>)("$name", ({ event, expected }) => { + expect(isMessageReceivedEvent(event)).toBe(expected); + }); + }); + + describe("isMessageSentEvent", () => { + it.each([ { name: "returns true for message:sent events with expected context", event: createInternalHookEvent("message", "sent", "test-session", { @@ -316,27 +300,25 @@ describe("hooks", () => { } satisfies MessageReceivedHookContext), expected: false, }, - ]; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(isMessageSentEvent(testCase.event)).toBe(testCase.expected); - }); - } + ] satisfies Array<{ + name: string; + event: ReturnType; + expected: boolean; + }>)("$name", ({ event, expected }) => { + expect(isMessageSentEvent(event)).toBe(expected); + }); }); describe("message type-guard shared negatives", () => { it("returns false for non-message and missing-context shapes", () => { - const cases: Array<{ - match: (event: ReturnType) => boolean; - }> = [ + const cases = [ { match: isMessageReceivedEvent, }, { match: isMessageSentEvent, }, - ]; + ] as const; const nonMessageEvent = createInternalHookEvent("command", "new", "test-session"); const missingReceivedContext = createInternalHookEvent( "message", @@ -353,8 +335,8 @@ describe("hooks", () => { // missing success }); - for (const testCase of cases) { - expect(testCase.match(nonMessageEvent)).toBe(false); + for (const { match } of cases) { + expect(match(nonMessageEvent)).toBe(false); } expect(isMessageReceivedEvent(missingReceivedContext)).toBe(false); expect(isMessageSentEvent(missingSentContext)).toBe(false); diff --git a/src/infra/exec-allowlist-matching.test.ts b/src/infra/exec-allowlist-matching.test.ts index 4376eefeff1..9da1fc40328 100644 --- a/src/infra/exec-allowlist-matching.test.ts +++ b/src/infra/exec-allowlist-matching.test.ts @@ -14,25 +14,28 @@ describe("exec allowlist matching", () => { { entries: [{ pattern: "/opt/**/rg" }], expectedPattern: "/opt/**/rg" }, { entries: [{ pattern: "/opt/*/rg" }], expectedPattern: null }, ]; - for (const testCase of cases) { - const match = matchAllowlist(testCase.entries, baseResolution); - expect(match?.pattern ?? null).toBe(testCase.expectedPattern); + for (const { entries, expectedPattern } of cases) { + const match = matchAllowlist(entries, baseResolution); + expect(match?.pattern ?? null).toBe(expectedPattern); } }); it("matches bare wildcard patterns against arbitrary resolved executables", () => { - expect(matchAllowlist([{ pattern: "*" }], baseResolution)?.pattern).toBe("*"); - expect( - matchAllowlist([{ pattern: "*" }], { + const cases = [ + baseResolution, + { rawExecutable: "python3", resolvedPath: "/usr/bin/python3", executableName: "python3", - })?.pattern, - ).toBe("*"); + }, + ] as const; + for (const resolution of cases) { + expect(matchAllowlist([{ pattern: "*" }], resolution)?.pattern).toBe("*"); + } }); it("matches absolute paths containing regex metacharacters literally", () => { - const plusPathCases = ["/usr/bin/g++", "/usr/bin/clang++"]; + const plusPathCases = ["/usr/bin/g++", "/usr/bin/clang++"] as const; for (const candidatePath of plusPathCases) { const match = matchAllowlist([{ pattern: candidatePath }], { rawExecutable: candidatePath, @@ -42,19 +45,26 @@ describe("exec allowlist matching", () => { expect(match?.pattern).toBe(candidatePath); } - expect( - matchAllowlist([{ pattern: "/usr/bin/*++" }], { - rawExecutable: "/usr/bin/g++", - resolvedPath: "/usr/bin/g++", - executableName: "g++", - })?.pattern, - ).toBe("/usr/bin/*++"); - expect( - matchAllowlist([{ pattern: "/opt/builds/tool[1](stable)" }], { - rawExecutable: "/opt/builds/tool[1](stable)", - resolvedPath: "/opt/builds/tool[1](stable)", - executableName: "tool[1](stable)", - })?.pattern, - ).toBe("/opt/builds/tool[1](stable)"); + const literalCases = [ + { + pattern: "/usr/bin/*++", + resolution: { + rawExecutable: "/usr/bin/g++", + resolvedPath: "/usr/bin/g++", + executableName: "g++", + }, + }, + { + pattern: "/opt/builds/tool[1](stable)", + resolution: { + rawExecutable: "/opt/builds/tool[1](stable)", + resolvedPath: "/opt/builds/tool[1](stable)", + executableName: "tool[1](stable)", + }, + }, + ] as const; + for (const { pattern, resolution } of literalCases) { + expect(matchAllowlist([{ pattern }], resolution)?.pattern).toBe(pattern); + } }); }); diff --git a/src/infra/exec-inline-eval.test.ts b/src/infra/exec-inline-eval.test.ts index c9cc4d81f3b..ff2e6673220 100644 --- a/src/infra/exec-inline-eval.test.ts +++ b/src/infra/exec-inline-eval.test.ts @@ -6,18 +6,15 @@ import { } from "./exec-inline-eval.js"; describe("exec inline eval detection", () => { - it("detects common interpreter eval flags", () => { - const cases = [ - { argv: ["python3", "-c", "print('hi')"], expected: "python3 -c" }, - { argv: ["/usr/bin/node", "--eval", "console.log('hi')"], expected: "node --eval" }, - { argv: ["perl", "-E", "say 1"], expected: "perl -e" }, - { argv: ["osascript", "-e", "beep"], expected: "osascript -e" }, - ]; - for (const testCase of cases) { - const hit = detectInterpreterInlineEvalArgv(testCase.argv); - expect(hit).not.toBeNull(); - expect(describeInterpreterInlineEval(hit!)).toBe(testCase.expected); - } + it.each([ + { argv: ["python3", "-c", "print('hi')"], expected: "python3 -c" }, + { argv: ["/usr/bin/node", "--eval", "console.log('hi')"], expected: "node --eval" }, + { argv: ["perl", "-E", "say 1"], expected: "perl -e" }, + { argv: ["osascript", "-e", "beep"], expected: "osascript -e" }, + ] as const)("detects interpreter eval flags for %j", ({ argv, expected }) => { + const hit = detectInterpreterInlineEvalArgv([...argv]); + expect(hit).not.toBeNull(); + expect(describeInterpreterInlineEval(hit!)).toBe(expected); }); it("ignores normal script execution", () => { diff --git a/src/infra/npm-integrity.test.ts b/src/infra/npm-integrity.test.ts index aa96da76fab..6e1664b840d 100644 --- a/src/infra/npm-integrity.test.ts +++ b/src/infra/npm-integrity.test.ts @@ -5,36 +5,34 @@ import { } from "./npm-integrity.js"; describe("resolveNpmIntegrityDrift", () => { - it("returns proceed=true when integrity is missing or unchanged", async () => { - const createPayload = vi.fn(() => "unused"); - const cases = [ - { - expectedIntegrity: undefined, - resolution: { integrity: "sha512-same", resolvedAt: "2026-01-01T00:00:00.000Z" }, - }, - { - expectedIntegrity: "sha512-same", - resolution: { resolvedAt: "2026-01-01T00:00:00.000Z" }, - }, - { - expectedIntegrity: "sha512-same", - resolution: { integrity: "sha512-same", resolvedAt: "2026-01-01T00:00:00.000Z" }, - }, - ]; - - for (const testCase of cases) { + it.each([ + { + expectedIntegrity: undefined, + resolution: { integrity: "sha512-same", resolvedAt: "2026-01-01T00:00:00.000Z" }, + }, + { + expectedIntegrity: "sha512-same", + resolution: { resolvedAt: "2026-01-01T00:00:00.000Z" }, + }, + { + expectedIntegrity: "sha512-same", + resolution: { integrity: "sha512-same", resolvedAt: "2026-01-01T00:00:00.000Z" }, + }, + ])( + "returns proceed=true when integrity is missing or unchanged: $expectedIntegrity", + async ({ expectedIntegrity, resolution }) => { + const createPayload = vi.fn(() => "unused"); await expect( resolveNpmIntegrityDrift({ spec: "@openclaw/test@1.0.0", - expectedIntegrity: testCase.expectedIntegrity, - resolution: testCase.resolution, + expectedIntegrity, + resolution, createPayload, }), ).resolves.toEqual({ proceed: true }); - } - - expect(createPayload).not.toHaveBeenCalled(); - }); + expect(createPayload).not.toHaveBeenCalled(); + }, + ); it("uses callback on integrity drift", async () => { const onIntegrityDrift = vi.fn(async () => false); diff --git a/src/logger.test.ts b/src/logger.test.ts index d438cf3a716..103c0211025 100644 --- a/src/logger.test.ts +++ b/src/logger.test.ts @@ -122,23 +122,17 @@ describe("globals", () => { }); describe("stripRedundantSubsystemPrefixForConsole", () => { - it("drops known subsystem prefixes", () => { - const cases = [ - { input: "discord: hello", subsystem: "discord", expected: "hello" }, - { input: "WhatsApp: hello", subsystem: "whatsapp", expected: "hello" }, - { input: "discord gateway: closed", subsystem: "discord", expected: "gateway: closed" }, - { - input: "[discord] connection stalled", - subsystem: "discord", - expected: "connection stalled", - }, - ]; - - for (const testCase of cases) { - expect(stripRedundantSubsystemPrefixForConsole(testCase.input, testCase.subsystem)).toBe( - testCase.expected, - ); - } + it.each([ + { input: "discord: hello", subsystem: "discord", expected: "hello" }, + { input: "WhatsApp: hello", subsystem: "whatsapp", expected: "hello" }, + { input: "discord gateway: closed", subsystem: "discord", expected: "gateway: closed" }, + { + input: "[discord] connection stalled", + subsystem: "discord", + expected: "connection stalled", + }, + ] as const)("drops known subsystem prefix for $input", ({ input, subsystem, expected }) => { + expect(stripRedundantSubsystemPrefixForConsole(input, subsystem)).toBe(expected); }); it("keeps messages that do not start with the subsystem", () => { diff --git a/src/markdown/whatsapp.test.ts b/src/markdown/whatsapp.test.ts index 07ee16d9225..e92f94179f7 100644 --- a/src/markdown/whatsapp.test.ts +++ b/src/markdown/whatsapp.test.ts @@ -2,27 +2,24 @@ import { describe, expect, it } from "vitest"; import { markdownToWhatsApp } from "./whatsapp.js"; describe("markdownToWhatsApp", () => { - it("handles common markdown-to-whatsapp conversions", () => { - const cases = [ - ["converts **bold** to *bold*", "**SOD Blast:**", "*SOD Blast:*"], - ["converts __bold__ to *bold*", "__important__", "*important*"], - ["converts ~~strikethrough~~ to ~strikethrough~", "~~deleted~~", "~deleted~"], - ["leaves single *italic* unchanged (already WhatsApp bold)", "*text*", "*text*"], - ["leaves _italic_ unchanged (already WhatsApp italic)", "_text_", "_text_"], - ["preserves inline code", "Use `**not bold**` here", "Use `**not bold**` here"], - [ - "handles mixed formatting", - "**bold** and ~~strike~~ and _italic_", - "*bold* and ~strike~ and _italic_", - ], - ["handles multiple bold segments", "**one** then **two**", "*one* then *two*"], - ["returns empty string for empty input", "", ""], - ["returns plain text unchanged", "no formatting here", "no formatting here"], - ["handles bold inside a sentence", "This is **very** important", "This is *very* important"], - ] as const; - for (const [name, input, expected] of cases) { - expect(markdownToWhatsApp(input), name).toBe(expected); - } + it.each([ + ["converts **bold** to *bold*", "**SOD Blast:**", "*SOD Blast:*"], + ["converts __bold__ to *bold*", "__important__", "*important*"], + ["converts ~~strikethrough~~ to ~strikethrough~", "~~deleted~~", "~deleted~"], + ["leaves single *italic* unchanged (already WhatsApp bold)", "*text*", "*text*"], + ["leaves _italic_ unchanged (already WhatsApp italic)", "_text_", "_text_"], + ["preserves inline code", "Use `**not bold**` here", "Use `**not bold**` here"], + [ + "handles mixed formatting", + "**bold** and ~~strike~~ and _italic_", + "*bold* and ~strike~ and _italic_", + ], + ["handles multiple bold segments", "**one** then **two**", "*one* then *two*"], + ["returns empty string for empty input", "", ""], + ["returns plain text unchanged", "no formatting here", "no formatting here"], + ["handles bold inside a sentence", "This is **very** important", "This is *very* important"], + ] as const)("handles markdown-to-whatsapp conversion: %s", (_name, input, expected) => { + expect(markdownToWhatsApp(input)).toBe(expected); }); it("preserves fenced code blocks", () => { diff --git a/src/media/mime.test.ts b/src/media/mime.test.ts index cdc05016ca5..1ba8601dc28 100644 --- a/src/media/mime.test.ts +++ b/src/media/mime.test.ts @@ -99,16 +99,12 @@ describe("extensionForMime", () => { }); describe("isAudioFileName", () => { - it("matches known audio extensions", () => { - const cases = [ - { fileName: "voice.mp3", expected: true }, - { fileName: "voice.caf", expected: true }, - { fileName: "voice.bin", expected: false }, - ] as const; - - for (const testCase of cases) { - expect(isAudioFileName(testCase.fileName)).toBe(testCase.expected); - } + it.each([ + { fileName: "voice.mp3", expected: true }, + { fileName: "voice.caf", expected: true }, + { fileName: "voice.bin", expected: false }, + ] as const)("matches audio extension for $fileName", ({ fileName, expected }) => { + expect(isAudioFileName(fileName)).toBe(expected); }); }); diff --git a/src/pairing/pairing-messages.test.ts b/src/pairing/pairing-messages.test.ts index a52c1499a48..94b9da62b4b 100644 --- a/src/pairing/pairing-messages.test.ts +++ b/src/pairing/pairing-messages.test.ts @@ -49,15 +49,13 @@ describe("buildPairingReply", () => { }, ] as const; - for (const testCase of cases) { - it(`formats pairing reply for ${testCase.channel}`, () => { - const text = buildPairingReply(testCase); - expectPairingReplyText(text, testCase); - // CLI commands should respect OPENCLAW_PROFILE when set (most tests run with isolated profile) - const commandRe = new RegExp( - `(?:openclaw|openclaw) --profile isolated pairing approve ${testCase.channel} ${testCase.code}`, - ); - expect(text).toMatch(commandRe); - }); - } + it.each(cases)("formats pairing reply for $channel", (testCase) => { + const text = buildPairingReply(testCase); + expectPairingReplyText(text, testCase); + // CLI commands should respect OPENCLAW_PROFILE when set (most tests run with isolated profile) + const commandRe = new RegExp( + `(?:openclaw|openclaw) --profile isolated pairing approve ${testCase.channel} ${testCase.code}`, + ); + expect(text).toMatch(commandRe); + }); }); diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 3e2c9c4d58a..a65ecd6e802 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -41,9 +41,9 @@ describe("resolveAgentRoute", () => { expected: "agent:main:whatsapp:direct:+15551234567", }, ]; - for (const testCase of cases) { + for (const { dmScope, expected } of cases) { const cfg: OpenClawConfig = { - session: { dmScope: testCase.dmScope }, + session: { dmScope }, }; const route = resolveAgentRoute({ cfg, @@ -51,7 +51,7 @@ describe("resolveAgentRoute", () => { accountId: null, peer: { kind: "direct", id: "+15551234567" }, }); - expect(route.sessionKey).toBe(testCase.expected); + expect(route.sessionKey).toBe(expected); expect(route.lastRoutePolicy).toBe("session"); } }); @@ -108,10 +108,10 @@ describe("resolveAgentRoute", () => { expected: "agent:main:discord:direct:alice", }, ]; - for (const testCase of cases) { + for (const { dmScope, channel, peerId, expected } of cases) { const cfg: OpenClawConfig = { session: { - dmScope: testCase.dmScope, + dmScope, identityLinks: { alice: ["telegram:111111111", "discord:222222222222222222"], }, @@ -119,11 +119,11 @@ describe("resolveAgentRoute", () => { }; const route = resolveAgentRoute({ cfg, - channel: testCase.channel, + channel, accountId: null, - peer: { kind: "direct", id: testCase.peerId }, + peer: { kind: "direct", id: peerId }, }); - expect(route.sessionKey).toBe(testCase.expected); + expect(route.sessionKey).toBe(expected); } }); diff --git a/src/shared/text/reasoning-tags.test.ts b/src/shared/text/reasoning-tags.test.ts index 86180e21a3f..7075b9f82df 100644 --- a/src/shared/text/reasoning-tags.test.ts +++ b/src/shared/text/reasoning-tags.test.ts @@ -2,6 +2,19 @@ import { describe, expect, it } from "vitest"; import { stripReasoningTagsFromText } from "./reasoning-tags.js"; describe("stripReasoningTagsFromText", () => { + const expectStrippedCases = ( + cases: ReadonlyArray<{ + input: string; + expected: string; + opts?: Parameters[1]; + name?: string; + }>, + ) => { + for (const { input, expected, opts, name } of cases) { + expect(stripReasoningTagsFromText(input, opts), name).toBe(expected); + } + }; + describe("basic functionality", () => { it("returns text unchanged when no reasoning tags present", () => { const input = "Hello, this is a normal message."; @@ -27,9 +40,7 @@ describe("stripReasoningTagsFromText", () => { expected: "X Y", }, ] as const; - for (const { name, input, expected } of cases) { - expect(stripReasoningTagsFromText(input), name).toBe(expected); - } + expectStrippedCases(cases); }); it("strips multiple reasoning blocks", () => { @@ -65,9 +76,7 @@ describe("stripReasoningTagsFromText", () => { expected: "```\ncode\n```\nvisible", }, ] as const; - for (const { input, expected } of cases) { - expect(stripReasoningTagsFromText(input)).toBe(expected); - } + expectStrippedCases(cases); }); }); @@ -127,9 +136,7 @@ describe("stripReasoningTagsFromText", () => { expected: "Start `unclosed end", }, ] as const; - for (const { input, expected } of cases) { - expect(stripReasoningTagsFromText(input)).toBe(expected); - } + expectStrippedCases(cases); }); it("handles nested and final tag behavior", () => { @@ -151,9 +158,7 @@ describe("stripReasoningTagsFromText", () => { expected: "A visible B", }, ] as const; - for (const { input, expected } of cases) { - expect(stripReasoningTagsFromText(input)).toBe(expected); - } + expectStrippedCases(cases); }); it("handles unicode, attributes, and case-insensitive tag names", () => { @@ -171,9 +176,7 @@ describe("stripReasoningTagsFromText", () => { expected: "A B", }, ] as const; - for (const { input, expected } of cases) { - expect(stripReasoningTagsFromText(input)).toBe(expected); - } + expectStrippedCases(cases); }); it("handles long content and pathological backtick patterns efficiently", () => { @@ -194,7 +197,7 @@ describe("stripReasoningTagsFromText", () => { const cases = [ { mode: "strict" as const, expected: "Before" }, { mode: "preserve" as const, expected: "Before unclosed content after" }, - ]; + ] as const; for (const { mode, expected } of cases) { expect(stripReasoningTagsFromText(input, { mode })).toBe(expected); } @@ -226,9 +229,7 @@ describe("stripReasoningTagsFromText", () => { opts: { trim: "start" as const }, }, ] as const; - for (const testCase of cases) { - expect(stripReasoningTagsFromText(testCase.input, testCase.opts)).toBe(testCase.expected); - } + expectStrippedCases(cases); }); }); diff --git a/src/utils/reaction-level.test.ts b/src/utils/reaction-level.test.ts index ec5cedb2e9f..cf988a06efd 100644 --- a/src/utils/reaction-level.test.ts +++ b/src/utils/reaction-level.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveReactionLevel } from "./reaction-level.js"; describe("resolveReactionLevel", () => { - const cases = [ + it.each([ { name: "defaults when value is missing", input: { @@ -55,11 +55,7 @@ describe("resolveReactionLevel", () => { agentReactionGuidance: "minimal", }, }, - ] as const; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(resolveReactionLevel(testCase.input)).toEqual(testCase.expected); - }); - } + ] as const)("$name", ({ input, expected }) => { + expect(resolveReactionLevel(input)).toEqual(expected); + }); }); diff --git a/src/utils/utils-misc.test.ts b/src/utils/utils-misc.test.ts index ae3d09d150e..ea585bfecdf 100644 --- a/src/utils/utils-misc.test.ts +++ b/src/utils/utils-misc.test.ts @@ -43,11 +43,7 @@ describe("parseBooleanValue", () => { }); describe("isReasoningTagProvider", () => { - const cases: Array<{ - name: string; - value: string | null | undefined; - expected: boolean; - }> = [ + it.each([ { name: "returns false for ollama - native reasoning field, no tags needed (#2279)", value: "ollama", @@ -82,13 +78,13 @@ describe("isReasoningTagProvider", () => { { name: "returns false for anthropic", value: "anthropic", expected: false }, { name: "returns false for openai", value: "openai", expected: false }, { name: "returns false for openrouter", value: "openrouter", expected: false }, - ]; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(isReasoningTagProvider(testCase.value)).toBe(testCase.expected); - }); - } + ] satisfies Array<{ + name: string; + value: string | null | undefined; + expected: boolean; + }>)("$name", ({ value, expected }) => { + expect(isReasoningTagProvider(value)).toBe(expected); + }); }); describe("splitShellArgs", () => {