mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 19:01:44 +00:00
test: dedupe utility and config suites
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'", () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user