test: dedupe utility and config suites

This commit is contained in:
Peter Steinberger
2026-03-28 00:26:10 +00:00
parent d8f97358d7
commit fef688fb7a
24 changed files with 1178 additions and 1312 deletions

View File

@@ -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);
}
});
});

View File

@@ -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);
});
});

View File

@@ -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'", () => {

View File

@@ -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);
},
);
});

View File

@@ -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);
});
});

View File

@@ -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", () => {

View File

@@ -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([

View File

@@ -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 || "<empty>").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", () => {

View File

@@ -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<void>;
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<string, unknown>;
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<string, unknown>;
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([
{

View File

@@ -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<OpenClawConfig>) => {
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<OpenClawConfig>) => {
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<OpenClawConfig>) => {
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<OpenClawConfig>) => {
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<OpenClawConfig>) => {
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<OpenClawConfig>) => {
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<OpenClawConfig>) => {
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<OpenClawConfig>) => {
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<OpenClawConfig>) => {
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 } },

View File

@@ -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<string, unknown>).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", () => {

View File

@@ -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<string, unknown>) => {
expect((config.memory as Record<string, unknown>).tokens).toBe(8192);
},
it.each([
{
name: "does not redact numeric tokens field",
snapshot: makeSnapshot({ memory: { tokens: 8192 } }),
assert: (config: Record<string, unknown>) => {
expect((config.memory as Record<string, unknown>).tokens).toBe(8192);
},
{
name: "does not redact softThresholdTokens",
snapshot: makeSnapshot({ compaction: { softThresholdTokens: 50000 } }),
assert: (config: Record<string, unknown>) => {
expect((config.compaction as Record<string, unknown>).softThresholdTokens).toBe(50000);
},
},
{
name: "does not redact softThresholdTokens",
snapshot: makeSnapshot({ compaction: { softThresholdTokens: 50000 } }),
assert: (config: Record<string, unknown>) => {
expect((config.compaction as Record<string, unknown>).softThresholdTokens).toBe(50000);
},
{
name: "does not redact string tokens field",
snapshot: makeSnapshot({ memory: { tokens: "should-not-be-redacted" } }),
assert: (config: Record<string, unknown>) => {
expect((config.memory as Record<string, unknown>).tokens).toBe("should-not-be-redacted");
},
},
{
name: "does not redact string tokens field",
snapshot: makeSnapshot({ memory: { tokens: "should-not-be-redacted" } }),
assert: (config: Record<string, unknown>) => {
expect((config.memory as Record<string, unknown>).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<string, unknown>) => {
const channels = config.channels as Record<string, Record<string, string>>;
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<string, unknown>) => {
const channels = config.channels as Record<string, Record<string, string>>;
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<string, unknown>);
}
},
] as const)("respects token-name redaction boundaries: $name", ({ snapshot, assert }) => {
const result = redactConfigSnapshot(snapshot);
assert(result.config as Record<string, unknown>);
});
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<string, unknown>,
restored: restored as Record<string, unknown>,
});

View File

@@ -188,11 +188,7 @@ describe("hooks", () => {
});
describe("isAgentBootstrapEvent", () => {
const cases: Array<{
name: string;
event: ReturnType<typeof createInternalHookEvent>;
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<typeof createInternalHookEvent>;
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<typeof createInternalHookEvent>;
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<typeof createInternalHookEvent>;
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<typeof createInternalHookEvent>;
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<typeof createInternalHookEvent>) => 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);

View File

@@ -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);
}
});
});

View File

@@ -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", () => {

View File

@@ -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);

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
}
});

View File

@@ -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<typeof stripReasoningTagsFromText>[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: "```\n<think>code</think>\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);
});
});

View File

@@ -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);
});
});

View File

@@ -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", () => {