From cd5f3fe0c1e3011ae26cd5da86ed4d23d2763bc7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 22:16:30 +0000 Subject: [PATCH] test(config): consolidate env/include scenario coverage --- src/config/env-substitution.test.ts | 530 ++++++++++++++----------- src/config/includes.test.ts | 271 +++++++------ src/shared/text/reasoning-tags.test.ts | 252 ++++++------ 3 files changed, 587 insertions(+), 466 deletions(-) diff --git a/src/config/env-substitution.test.ts b/src/config/env-substitution.test.ts index cb9924e52..30ad33343 100644 --- a/src/config/env-substitution.test.ts +++ b/src/config/env-substitution.test.ts @@ -3,287 +3,349 @@ import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js" describe("resolveConfigEnvVars", () => { describe("basic substitution", () => { - it("substitutes a single env var", () => { - const result = resolveConfigEnvVars({ key: "${FOO}" }, { FOO: "bar" }); - expect(result).toEqual({ key: "bar" }); - }); + it("substitutes direct, inline, repeated, and multi-var patterns", () => { + const scenarios: Array<{ + name: string; + config: unknown; + env: Record; + expected: unknown; + }> = [ + { + name: "single env var", + config: { key: "${FOO}" }, + env: { FOO: "bar" }, + expected: { key: "bar" }, + }, + { + name: "multiple env vars in same string", + config: { key: "${A}/${B}" }, + env: { A: "x", B: "y" }, + expected: { key: "x/y" }, + }, + { + name: "inline prefix/suffix", + config: { key: "prefix-${FOO}-suffix" }, + env: { FOO: "bar" }, + expected: { key: "prefix-bar-suffix" }, + }, + { + name: "same var repeated", + config: { key: "${FOO}:${FOO}" }, + env: { FOO: "bar" }, + expected: { key: "bar:bar" }, + }, + ]; - it("substitutes multiple different env vars in same string", () => { - const result = resolveConfigEnvVars({ key: "${A}/${B}" }, { A: "x", B: "y" }); - expect(result).toEqual({ key: "x/y" }); - }); - - it("substitutes inline with prefix and suffix", () => { - const result = resolveConfigEnvVars({ key: "prefix-${FOO}-suffix" }, { FOO: "bar" }); - expect(result).toEqual({ key: "prefix-bar-suffix" }); - }); - - it("substitutes same var multiple times", () => { - const result = resolveConfigEnvVars({ key: "${FOO}:${FOO}" }, { FOO: "bar" }); - expect(result).toEqual({ key: "bar:bar" }); + for (const scenario of scenarios) { + const result = resolveConfigEnvVars(scenario.config, scenario.env); + expect(result, scenario.name).toEqual(scenario.expected); + } }); }); describe("nested structures", () => { - it("substitutes in nested objects", () => { - const result = resolveConfigEnvVars( + it("substitutes variables in nested objects and arrays", () => { + const scenarios: Array<{ + name: string; + config: unknown; + env: Record; + expected: unknown; + }> = [ { - outer: { - inner: { - key: "${API_KEY}", - }, + name: "nested object", + config: { outer: { inner: { key: "${API_KEY}" } } }, + env: { API_KEY: "secret123" }, + expected: { outer: { inner: { key: "secret123" } } }, + }, + { + name: "flat array", + config: { items: ["${A}", "${B}", "${C}"] }, + env: { A: "1", B: "2", C: "3" }, + expected: { items: ["1", "2", "3"] }, + }, + { + name: "array of objects", + config: { + providers: [ + { name: "openai", apiKey: "${OPENAI_KEY}" }, + { name: "anthropic", apiKey: "${ANTHROPIC_KEY}" }, + ], + }, + env: { OPENAI_KEY: "sk-xxx", ANTHROPIC_KEY: "sk-yyy" }, + expected: { + providers: [ + { name: "openai", apiKey: "sk-xxx" }, + { name: "anthropic", apiKey: "sk-yyy" }, + ], }, }, - { API_KEY: "secret123" }, - ); - expect(result).toEqual({ - outer: { - inner: { - key: "secret123", - }, - }, - }); - }); + ]; - it("substitutes in arrays", () => { - const result = resolveConfigEnvVars( - { items: ["${A}", "${B}", "${C}"] }, - { A: "1", B: "2", C: "3" }, - ); - expect(result).toEqual({ items: ["1", "2", "3"] }); - }); - - it("substitutes in deeply nested arrays and objects", () => { - const result = resolveConfigEnvVars( - { - providers: [ - { name: "openai", apiKey: "${OPENAI_KEY}" }, - { name: "anthropic", apiKey: "${ANTHROPIC_KEY}" }, - ], - }, - { OPENAI_KEY: "sk-xxx", ANTHROPIC_KEY: "sk-yyy" }, - ); - expect(result).toEqual({ - providers: [ - { name: "openai", apiKey: "sk-xxx" }, - { name: "anthropic", apiKey: "sk-yyy" }, - ], - }); + for (const scenario of scenarios) { + const result = resolveConfigEnvVars(scenario.config, scenario.env); + expect(result, scenario.name).toEqual(scenario.expected); + } }); }); describe("missing env var handling", () => { - it("throws MissingEnvVarError for missing env var", () => { - expect(() => resolveConfigEnvVars({ key: "${MISSING}" }, {})).toThrow(MissingEnvVarError); - }); + it("throws MissingEnvVarError with var name and config path details", () => { + const scenarios: Array<{ + name: string; + config: unknown; + env: Record; + varName: string; + configPath: string; + }> = [ + { + name: "missing top-level var", + config: { key: "${MISSING}" }, + env: {}, + varName: "MISSING", + configPath: "key", + }, + { + name: "missing nested var", + config: { outer: { inner: { key: "${MISSING_VAR}" } } }, + env: {}, + varName: "MISSING_VAR", + configPath: "outer.inner.key", + }, + { + name: "missing var in array element", + config: { items: ["ok", "${MISSING}"] }, + env: { OK: "val" }, + varName: "MISSING", + configPath: "items[1]", + }, + { + name: "empty string env value treated as missing", + config: { key: "${EMPTY}" }, + env: { EMPTY: "" }, + varName: "EMPTY", + configPath: "key", + }, + ]; - it("includes var name in error", () => { - try { - resolveConfigEnvVars({ key: "${MISSING_VAR}" }, {}); - throw new Error("Expected to throw"); - } catch (err) { - expect(err).toBeInstanceOf(MissingEnvVarError); - const error = err as MissingEnvVarError; - expect(error.varName).toBe("MISSING_VAR"); + for (const scenario of scenarios) { + try { + resolveConfigEnvVars(scenario.config, scenario.env); + expect.fail(`${scenario.name}: expected MissingEnvVarError`); + } catch (err) { + expect(err, scenario.name).toBeInstanceOf(MissingEnvVarError); + const error = err as MissingEnvVarError; + expect(error.varName, scenario.name).toBe(scenario.varName); + expect(error.configPath, scenario.name).toBe(scenario.configPath); + } } }); - - it("includes config path in error", () => { - try { - resolveConfigEnvVars({ outer: { inner: { key: "${MISSING}" } } }, {}); - throw new Error("Expected to throw"); - } catch (err) { - expect(err).toBeInstanceOf(MissingEnvVarError); - const error = err as MissingEnvVarError; - expect(error.configPath).toBe("outer.inner.key"); - } - }); - - it("includes array index in config path", () => { - try { - resolveConfigEnvVars({ items: ["ok", "${MISSING}"] }, { OK: "val" }); - throw new Error("Expected to throw"); - } catch (err) { - expect(err).toBeInstanceOf(MissingEnvVarError); - const error = err as MissingEnvVarError; - expect(error.configPath).toBe("items[1]"); - } - }); - - it("treats empty string env var as missing", () => { - expect(() => resolveConfigEnvVars({ key: "${EMPTY}" }, { EMPTY: "" })).toThrow( - MissingEnvVarError, - ); - }); }); describe("escape syntax", () => { - it("outputs literal ${VAR} when escaped with $$", () => { - const result = resolveConfigEnvVars({ key: "$${VAR}" }, { VAR: "value" }); - expect(result).toEqual({ key: "${VAR}" }); - }); + it("handles escaped placeholders alongside regular substitutions", () => { + const scenarios: Array<{ + name: string; + config: unknown; + env: Record; + expected: unknown; + }> = [ + { + name: "escaped placeholder stays literal", + config: { key: "$${VAR}" }, + env: { VAR: "value" }, + expected: { key: "${VAR}" }, + }, + { + name: "mix of escaped and unescaped vars", + config: { key: "${REAL}/$${LITERAL}" }, + env: { REAL: "resolved" }, + expected: { key: "resolved/${LITERAL}" }, + }, + { + name: "escaped first, unescaped second", + config: { key: "$${FOO} ${FOO}" }, + env: { FOO: "bar" }, + expected: { key: "${FOO} bar" }, + }, + { + name: "unescaped first, escaped second", + config: { key: "${FOO} $${FOO}" }, + env: { FOO: "bar" }, + expected: { key: "bar ${FOO}" }, + }, + { + name: "multiple escaped placeholders", + config: { key: "$${A}:$${B}" }, + env: {}, + expected: { key: "${A}:${B}" }, + }, + { + name: "env values are not unescaped", + config: { key: "${FOO}" }, + env: { FOO: "$${BAR}" }, + expected: { key: "$${BAR}" }, + }, + ]; - it("handles mix of escaped and unescaped", () => { - const result = resolveConfigEnvVars({ key: "${REAL}/$${LITERAL}" }, { REAL: "resolved" }); - expect(result).toEqual({ key: "resolved/${LITERAL}" }); - }); - - it("handles escaped and unescaped of the same var (escaped first)", () => { - const result = resolveConfigEnvVars({ key: "$${FOO} ${FOO}" }, { FOO: "bar" }); - expect(result).toEqual({ key: "${FOO} bar" }); - }); - - it("handles escaped and unescaped of the same var (unescaped first)", () => { - const result = resolveConfigEnvVars({ key: "${FOO} $${FOO}" }, { FOO: "bar" }); - expect(result).toEqual({ key: "bar ${FOO}" }); - }); - - it("handles multiple escaped vars", () => { - const result = resolveConfigEnvVars({ key: "$${A}:$${B}" }, {}); - expect(result).toEqual({ key: "${A}:${B}" }); - }); - - it("does not unescape $${VAR} sequences from env values", () => { - const result = resolveConfigEnvVars({ key: "${FOO}" }, { FOO: "$${BAR}" }); - expect(result).toEqual({ key: "$${BAR}" }); + for (const scenario of scenarios) { + const result = resolveConfigEnvVars(scenario.config, scenario.env); + expect(result, scenario.name).toEqual(scenario.expected); + } }); }); - describe("non-matching patterns unchanged", () => { - it("leaves $VAR (no braces) unchanged", () => { - const result = resolveConfigEnvVars({ key: "$VAR" }, { VAR: "value" }); - expect(result).toEqual({ key: "$VAR" }); + describe("pattern matching rules", () => { + it("leaves non-matching placeholders unchanged", () => { + const scenarios: Array<{ + name: string; + config: unknown; + env: Record; + expected: unknown; + }> = [ + { + name: "$VAR (no braces)", + config: { key: "$VAR" }, + env: { VAR: "value" }, + expected: { key: "$VAR" }, + }, + { + name: "lowercase placeholder", + config: { key: "${lowercase}" }, + env: { lowercase: "value" }, + expected: { key: "${lowercase}" }, + }, + { + name: "mixed-case placeholder", + config: { key: "${MixedCase}" }, + env: { MixedCase: "value" }, + expected: { key: "${MixedCase}" }, + }, + { + name: "invalid numeric prefix", + config: { key: "${123INVALID}" }, + env: {}, + expected: { key: "${123INVALID}" }, + }, + ]; + + for (const scenario of scenarios) { + const result = resolveConfigEnvVars(scenario.config, scenario.env); + expect(result, scenario.name).toEqual(scenario.expected); + } }); - it("leaves ${lowercase} unchanged (uppercase only)", () => { - const result = resolveConfigEnvVars({ key: "${lowercase}" }, { lowercase: "value" }); - expect(result).toEqual({ key: "${lowercase}" }); - }); + it("substitutes valid uppercase/underscore placeholder names", () => { + const scenarios: Array<{ + name: string; + config: unknown; + env: Record; + expected: unknown; + }> = [ + { + name: "underscore-prefixed name", + config: { key: "${_UNDERSCORE_START}" }, + env: { _UNDERSCORE_START: "valid" }, + expected: { key: "valid" }, + }, + { + name: "name with numbers", + config: { key: "${VAR_WITH_NUMBERS_123}" }, + env: { VAR_WITH_NUMBERS_123: "valid" }, + expected: { key: "valid" }, + }, + ]; - it("leaves ${MixedCase} unchanged", () => { - const result = resolveConfigEnvVars({ key: "${MixedCase}" }, { MixedCase: "value" }); - expect(result).toEqual({ key: "${MixedCase}" }); - }); - - it("leaves ${123INVALID} unchanged (must start with letter or underscore)", () => { - const result = resolveConfigEnvVars({ key: "${123INVALID}" }, {}); - expect(result).toEqual({ key: "${123INVALID}" }); - }); - - it("substitutes ${_UNDERSCORE_START} (valid)", () => { - const result = resolveConfigEnvVars( - { key: "${_UNDERSCORE_START}" }, - { _UNDERSCORE_START: "valid" }, - ); - expect(result).toEqual({ key: "valid" }); - }); - - it("substitutes ${VAR_WITH_NUMBERS_123} (valid)", () => { - const result = resolveConfigEnvVars( - { key: "${VAR_WITH_NUMBERS_123}" }, - { VAR_WITH_NUMBERS_123: "valid" }, - ); - expect(result).toEqual({ key: "valid" }); + for (const scenario of scenarios) { + const result = resolveConfigEnvVars(scenario.config, scenario.env); + expect(result, scenario.name).toEqual(scenario.expected); + } }); }); describe("passthrough behavior", () => { it("passes through primitives unchanged", () => { - expect(resolveConfigEnvVars("hello", {})).toBe("hello"); - expect(resolveConfigEnvVars(42, {})).toBe(42); - expect(resolveConfigEnvVars(true, {})).toBe(true); - expect(resolveConfigEnvVars(null, {})).toBe(null); + for (const value of ["hello", 42, true, null]) { + expect(resolveConfigEnvVars(value, {})).toBe(value); + } }); - it("passes through empty object", () => { - expect(resolveConfigEnvVars({}, {})).toEqual({}); - }); + it("preserves empty and non-string containers", () => { + const scenarios: Array<{ config: unknown; expected: unknown }> = [ + { config: {}, expected: {} }, + { config: [], expected: [] }, + { + config: { num: 42, bool: true, nil: null, arr: [1, 2] }, + expected: { num: 42, bool: true, nil: null, arr: [1, 2] }, + }, + ]; - it("passes through empty array", () => { - expect(resolveConfigEnvVars([], {})).toEqual([]); - }); - - it("passes through non-string values in objects", () => { - const result = resolveConfigEnvVars({ num: 42, bool: true, nil: null, arr: [1, 2] }, {}); - expect(result).toEqual({ num: 42, bool: true, nil: null, arr: [1, 2] }); + for (const scenario of scenarios) { + expect(resolveConfigEnvVars(scenario.config, {})).toEqual(scenario.expected); + } }); }); describe("real-world config patterns", () => { - it("substitutes API keys in provider config", () => { - const config = { - models: { - providers: { - "vercel-gateway": { - apiKey: "${VERCEL_GATEWAY_API_KEY}", + it("substitutes provider, gateway, and base URL config values", () => { + const scenarios: Array<{ + name: string; + config: unknown; + env: Record; + expected: unknown; + }> = [ + { + name: "provider API keys", + config: { + models: { + providers: { + "vercel-gateway": { apiKey: "${VERCEL_GATEWAY_API_KEY}" }, + openai: { apiKey: "${OPENAI_API_KEY}" }, + }, }, - openai: { - apiKey: "${OPENAI_API_KEY}", + }, + env: { + VERCEL_GATEWAY_API_KEY: "vg_key_123", + OPENAI_API_KEY: "sk-xxx", + }, + expected: { + models: { + providers: { + "vercel-gateway": { apiKey: "vg_key_123" }, + openai: { apiKey: "sk-xxx" }, + }, }, }, }, - }; - const env = { - VERCEL_GATEWAY_API_KEY: "vg_key_123", - OPENAI_API_KEY: "sk-xxx", - }; - const result = resolveConfigEnvVars(config, env); - expect(result).toEqual({ - models: { - providers: { - "vercel-gateway": { - apiKey: "vg_key_123", + { + name: "gateway auth token", + config: { gateway: { auth: { token: "${OPENCLAW_GATEWAY_TOKEN}" } } }, + env: { OPENCLAW_GATEWAY_TOKEN: "secret-token" }, + expected: { gateway: { auth: { token: "secret-token" } } }, + }, + { + name: "provider base URL composition", + config: { + models: { + providers: { + custom: { baseUrl: "${CUSTOM_API_BASE}/v1" }, + }, }, - openai: { - apiKey: "sk-xxx", + }, + env: { CUSTOM_API_BASE: "https://api.example.com" }, + expected: { + models: { + providers: { + custom: { baseUrl: "https://api.example.com/v1" }, + }, }, }, }, - }); - }); + ]; - it("substitutes gateway auth token", () => { - const config = { - gateway: { - auth: { - token: "${OPENCLAW_GATEWAY_TOKEN}", - }, - }, - }; - const result = resolveConfigEnvVars(config, { - OPENCLAW_GATEWAY_TOKEN: "secret-token", - }); - expect(result).toEqual({ - gateway: { - auth: { - token: "secret-token", - }, - }, - }); - }); - - it("substitutes base URL with env var", () => { - const config = { - models: { - providers: { - custom: { - baseUrl: "${CUSTOM_API_BASE}/v1", - }, - }, - }, - }; - const result = resolveConfigEnvVars(config, { - CUSTOM_API_BASE: "https://api.example.com", - }); - expect(result).toEqual({ - models: { - providers: { - custom: { - baseUrl: "https://api.example.com/v1", - }, - }, - }, - }); + for (const scenario of scenarios) { + const result = resolveConfigEnvVars(scenario.config, scenario.env); + expect(result, scenario.name).toEqual(scenario.expected); + } }); }); }); diff --git a/src/config/includes.test.ts b/src/config/includes.test.ts index b228d4b97..38360642e 100644 --- a/src/config/includes.test.ts +++ b/src/config/includes.test.ts @@ -63,28 +63,22 @@ function expectResolveIncludeError( } describe("resolveConfigIncludes", () => { - it("passes through primitives unchanged", () => { - expect(resolve("hello")).toBe("hello"); - expect(resolve(42)).toBe(42); - expect(resolve(true)).toBe(true); - expect(resolve(null)).toBe(null); - }); + 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; - it("passes through arrays with recursion", () => { - expect(resolve([1, 2, { a: 1 }])).toEqual([1, 2, { a: 1 }]); - }); - - it("passes through objects without $include", () => { - const obj = { foo: "bar", nested: { x: 1 } }; - expect(resolve(obj)).toEqual(obj); - }); - - it("resolves single file $include", () => { - const files = { [configPath("agents.json")]: { list: [{ id: "main" }] } }; - const obj = { agents: { $include: "./agents.json" } }; - expect(resolve(obj, files)).toEqual({ - agents: { list: [{ id: "main" }] }, - }); + for (const { value, expected } of cases) { + expect(resolve(value)).toEqual(expected); + } }); it("rejects absolute path outside config directory (CWE-22)", () => { @@ -94,44 +88,66 @@ describe("resolveConfigIncludes", () => { expectResolveIncludeError(() => resolve(obj, files), /escapes config directory/); }); - it("resolves array $include with deep merge", () => { - const files = { - [configPath("a.json")]: { "group-a": ["agent1"] }, - [configPath("b.json")]: { "group-b": ["agent2"] }, - }; - const obj = { broadcast: { $include: ["./a.json", "./b.json"] } }; - expect(resolve(obj, files)).toEqual({ - broadcast: { - "group-a": ["agent1"], - "group-b": ["agent2"], + 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("deep merges overlapping keys in array $include", () => { - const files = { - [configPath("a.json")]: { agents: { defaults: { workspace: "~/a" } } }, - [configPath("b.json")]: { agents: { list: [{ id: "main" }] } }, - }; - const obj = { $include: ["./a.json", "./b.json"] }; - expect(resolve(obj, files)).toEqual({ - agents: { - defaults: { workspace: "~/a" }, - 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 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); + } }); - it("merges $include with sibling keys", () => { + it("merges include content with sibling keys and sibling overrides", () => { const files = { [configPath("base.json")]: { a: 1, b: 2 } }; - const obj = { $include: "./base.json", c: 3 }; - expect(resolve(obj, files)).toEqual({ a: 1, b: 2, c: 3 }); - }); - - it("sibling keys override included values", () => { - const files = { [configPath("base.json")]: { a: 1, b: 2 } }; - const obj = { $include: "./base.json", b: 99 }; - expect(resolve(obj, files)).toEqual({ a: 1, b: 99 }); + 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); + } }); it("throws when sibling keys are used with non-object includes", () => { @@ -160,21 +176,25 @@ describe("resolveConfigIncludes", () => { }); }); - it("throws ConfigIncludeError for missing file", () => { - const obj = { $include: "./missing.json" }; - expectResolveIncludeError(() => resolve(obj), /Failed to read include file/); - }); + 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; - it("throws ConfigIncludeError for invalid JSON", () => { - const resolver: IncludeResolver = { - readFile: () => "{ invalid json }", - parseJson: JSON.parse, - }; - const obj = { $include: "./bad.json" }; - expectResolveIncludeError( - () => resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver), - /Failed to parse include file/, - ); + for (const testCase of cases) { + expectResolveIncludeError(testCase.run, testCase.pattern); + } }); it("throws CircularIncludeError for circular includes", () => { @@ -268,43 +288,53 @@ describe("resolveConfigIncludes", () => { ); }); - it("handles relative paths correctly", () => { - const files = { - [configPath("clients", "mueller", "agents.json")]: { id: "mueller" }, - }; - const obj = { agent: { $include: "./clients/mueller/agents.json" } }; - expect(resolve(obj, files)).toEqual({ - agent: { id: "mueller" }, - }); + 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" }, + }, + }, + { + 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; + for (const testCase of cases) { + expect(resolve(testCase.obj, testCase.files)).toEqual(testCase.expected); + } }); - it("applies nested includes before sibling overrides", () => { - const files = { - [configPath("base.json")]: { nested: { $include: "./nested.json" } }, - [configPath("nested.json")]: { a: 1, b: 2 }, - }; - const obj = { $include: "./base.json", nested: { b: 9 } }; - expect(resolve(obj, files)).toEqual({ - nested: { a: 1, b: 9 }, - }); - }); - - it("rejects parent directory traversal escaping config directory (CWE-22)", () => { - const files = { [sharedPath("common.json")]: { shared: true } }; - const obj = { $include: "../../shared/common.json" }; + it("enforces traversal boundaries while allowing safe nested-parent paths", () => { expectResolveIncludeError( - () => resolve(obj, files, configPath("sub", "openclaw.json")), + () => + resolve( + { $include: "../../shared/common.json" }, + { [sharedPath("common.json")]: { shared: true } }, + configPath("sub", "openclaw.json"), + ), /escapes config directory/, ); - }); - it("allows nested parent traversal when path stays under top-level config directory", () => { - const files = { - [configPath("sub", "child.json")]: { $include: "../shared/common.json" }, - [configPath("shared", "common.json")]: { shared: true }, - }; - const obj = { $include: "./sub/child.json" }; - expect(resolve(obj, files)).toEqual({ + expect( + resolve( + { $include: "./sub/child.json" }, + { + [configPath("sub", "child.json")]: { $include: "../shared/common.json" }, + [configPath("shared", "common.json")]: { shared: true }, + }, + ), + ).toEqual({ shared: true, }); }); @@ -536,27 +566,30 @@ describe("security: path traversal protection (CWE-22)", () => { }); describe("prototype pollution protection", () => { - it("blocks __proto__ keys from polluting Object.prototype", () => { - const result = deepMerge({}, JSON.parse('{"__proto__":{"polluted":true}}')); - expect((Object.prototype as Record).polluted).toBeUndefined(); - expect(result).toEqual({}); - }); + it("blocks prototype pollution vectors in shallow and nested merges", () => { + const cases = [ + { + base: {}, + incoming: JSON.parse('{"__proto__":{"polluted":true}}'), + expected: {}, + }, + { + base: { safe: 1 }, + incoming: { prototype: { x: 1 }, constructor: { y: 2 }, normal: 3 }, + expected: { safe: 1, normal: 3 }, + }, + { + base: { nested: { a: 1 } }, + incoming: { nested: JSON.parse('{"__proto__":{"polluted":true}}') }, + expected: { nested: { a: 1 } }, + }, + ] as const; - it("blocks prototype and constructor keys", () => { - const result = deepMerge( - { safe: 1 }, - { prototype: { x: 1 }, constructor: { y: 2 }, normal: 3 }, - ); - expect(result).toEqual({ safe: 1, normal: 3 }); - }); - - it("blocks __proto__ in nested merges", () => { - const result = deepMerge( - { nested: { a: 1 } }, - { nested: JSON.parse('{"__proto__":{"polluted":true}}') }, - ); - expect((Object.prototype as Record).polluted).toBeUndefined(); - expect(result).toEqual({ nested: { a: 1 } }); + for (const testCase of cases) { + const result = deepMerge(testCase.base, testCase.incoming); + expect((Object.prototype as Record).polluted).toBeUndefined(); + expect(result).toEqual(testCase.expected); + } }); }); diff --git a/src/shared/text/reasoning-tags.test.ts b/src/shared/text/reasoning-tags.test.ts index 35336f94f..40cd133be 100644 --- a/src/shared/text/reasoning-tags.test.ts +++ b/src/shared/text/reasoning-tags.test.ts @@ -54,145 +54,171 @@ describe("stripReasoningTagsFromText", () => { } }); - it("handles mixed real tags and code tags", () => { - const input = "hiddenVisible text with `` example."; - expect(stripReasoningTagsFromText(input)).toBe("Visible text with `` example."); - }); - - it("handles code block followed by real tags", () => { - const input = "```\ncode\n```\nreal hiddenvisible"; - expect(stripReasoningTagsFromText(input)).toBe("```\ncode\n```\nvisible"); + it("handles mixed code-tag and real-tag content", () => { + const cases = [ + { + input: "hiddenVisible text with `` example.", + expected: "Visible text with `` example.", + }, + { + input: "```\ncode\n```\nreal hiddenvisible", + expected: "```\ncode\n```\nvisible", + }, + ] as const; + for (const { input, expected } of cases) { + expect(stripReasoningTagsFromText(input)).toBe(expected); + } }); }); describe("edge cases", () => { - it("preserves unclosed { - const input = "Here is how to use { + const cases = [ + { + input: "Here is how to use ", + expected: "You can start with content< /think > B", + expected: "A B", + }, + { + input: "", + expected: "", + }, + { + input: null as unknown as string, + expected: null, + }, + ] as const; + for (const { input, expected } of cases) { + expect(stripReasoningTagsFromText(input)).toBe(expected); + } }); - it("strips lone closing tag outside code", () => { - const input = "You can start with "; - expect(stripReasoningTagsFromText(input)).toBe( - "You can start with { + const cases = [ + { + input: "Example:\n~~~\nreasoning\n~~~\nDone!", + expected: "Example:\n~~~\nreasoning\n~~~\nDone!", + }, + { + input: "Example:\n~~~js\ncode\n~~~", + expected: "Example:\n~~~js\ncode\n~~~", + }, + { + input: "Use ``code`` with hidden text", + expected: "Use ``code`` with text", + }, + { + input: "Before\n```\ncode\n```\nAfter with hidden", + expected: "Before\n```\ncode\n```\nAfter with", + }, + { + input: "```\nnot protected\n~~~\ntext", + expected: "```\nnot protected\n~~~\ntext", + }, + { + input: "Start `unclosed hidden end", + expected: "Start `unclosed end", + }, + ] as const; + for (const { input, expected } of cases) { + expect(stripReasoningTagsFromText(input)).toBe(expected); + } }); - it("handles tags with whitespace", () => { - const input = "A < think >content< /think > B"; - expect(stripReasoningTagsFromText(input)).toBe("A B"); + it("handles nested and final tag behavior", () => { + const cases = [ + { + input: "outer inner still outervisible", + expected: "still outervisible", + }, + { + input: "A1B2C", + expected: "A1B2C", + }, + { + input: "`` in code, visible outside", + expected: "`` in code, visible outside", + }, + ] as const; + for (const { input, expected } of cases) { + expect(stripReasoningTagsFromText(input)).toBe(expected); + } }); - it("handles empty and null-ish inputs", () => { - expect(stripReasoningTagsFromText("")).toBe(""); - expect(stripReasoningTagsFromText(null as unknown as string)).toBe(null); + it("handles unicode, attributes, and case-insensitive tag names", () => { + const cases = [ + { + input: "你好 思考 🤔 世界", + expected: "你好 世界", + }, + { + input: "A hidden B", + expected: "A B", + }, + { + input: "A hidden also hidden B", + expected: "A B", + }, + ] as const; + for (const { input, expected } of cases) { + expect(stripReasoningTagsFromText(input)).toBe(expected); + } }); - it("preserves think tags inside tilde fenced code blocks", () => { - const input = "Example:\n~~~\nreasoning\n~~~\nDone!"; - expect(stripReasoningTagsFromText(input)).toBe(input); - }); - - it("preserves tags in tilde block at EOF without trailing newline", () => { - const input = "Example:\n~~~js\ncode\n~~~"; - expect(stripReasoningTagsFromText(input)).toBe(input); - }); - - it("handles nested think patterns (first close ends block)", () => { - const input = "outer inner still outervisible"; - expect(stripReasoningTagsFromText(input)).toBe("still outervisible"); - }); - - it("strips final tag markup but preserves content (by design)", () => { - const input = "A1B2C"; - expect(stripReasoningTagsFromText(input)).toBe("A1B2C"); - }); - - it("preserves final tags in inline code (markup only stripped outside)", () => { - const input = "`` in code, visible outside"; - expect(stripReasoningTagsFromText(input)).toBe("`` in code, visible outside"); - }); - - it("handles double backtick inline code with tags", () => { - const input = "Use ``code`` with hidden text"; - expect(stripReasoningTagsFromText(input)).toBe("Use ``code`` with text"); - }); - - it("handles fenced code blocks with content", () => { - const input = "Before\n```\ncode\n```\nAfter with hidden"; - expect(stripReasoningTagsFromText(input)).toBe("Before\n```\ncode\n```\nAfter with"); - }); - - it("does not match mismatched fence types (``` vs ~~~)", () => { - const input = "```\nnot protected\n~~~\ntext"; - const result = stripReasoningTagsFromText(input); - expect(result).toBe(input); - }); - - it("handles unicode content inside and around tags", () => { - const input = "你好 思考 🤔 世界"; - expect(stripReasoningTagsFromText(input)).toBe("你好 世界"); - }); - - it("handles very long content between tags efficiently", () => { + it("handles long content and pathological backtick patterns efficiently", () => { const longContent = "x".repeat(10000); - const input = `${longContent}visible`; - expect(stripReasoningTagsFromText(input)).toBe("visible"); - }); + expect(stripReasoningTagsFromText(`${longContent}visible`)).toBe("visible"); - it("handles tags with attributes", () => { - const input = "A hidden B"; - expect(stripReasoningTagsFromText(input)).toBe("A B"); - }); - - it("is case-insensitive for tag names", () => { - const input = "A hidden also hidden B"; - expect(stripReasoningTagsFromText(input)).toBe("A B"); - }); - - it("handles pathological nested backtick patterns without hanging", () => { - const input = "`".repeat(100) + "test" + "`".repeat(100); + const pathological = "`".repeat(100) + "test" + "`".repeat(100); const start = Date.now(); - stripReasoningTagsFromText(input); + stripReasoningTagsFromText(pathological); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(1000); }); - - it("handles unclosed inline code gracefully", () => { - const input = "Start `unclosed hidden end"; - const result = stripReasoningTagsFromText(input); - expect(result).toBe("Start `unclosed end"); - }); }); describe("strict vs preserve mode", () => { - it("strict mode truncates on unclosed tag", () => { + it("applies strict and preserve modes to unclosed tags", () => { const input = "Before unclosed content after"; - expect(stripReasoningTagsFromText(input, { mode: "strict" })).toBe("Before"); - }); - - it("preserve mode keeps content after unclosed tag", () => { - const input = "Before unclosed content after"; - expect(stripReasoningTagsFromText(input, { mode: "preserve" })).toBe( - "Before unclosed content after", - ); + const cases = [ + { mode: "strict" as const, expected: "Before" }, + { mode: "preserve" as const, expected: "Before unclosed content after" }, + ]; + for (const { mode, expected } of cases) { + expect(stripReasoningTagsFromText(input, { mode })).toBe(expected); + } }); }); describe("trim options", () => { - it("trims both sides by default", () => { - const input = " x result y "; - expect(stripReasoningTagsFromText(input)).toBe("result"); - }); - - it("trim=none preserves whitespace", () => { - const input = " x result "; - expect(stripReasoningTagsFromText(input, { trim: "none" })).toBe(" result "); - }); - - it("trim=start only trims start", () => { - const input = " x result "; - expect(stripReasoningTagsFromText(input, { trim: "start" })).toBe("result "); + it("applies configured trim strategies", () => { + const cases = [ + { + input: " x result y ", + expected: "result", + opts: undefined, + }, + { + input: " x result ", + expected: " result ", + opts: { trim: "none" as const }, + }, + { + input: " x result ", + expected: "result ", + opts: { trim: "start" as const }, + }, + ] as const; + for (const testCase of cases) { + expect(stripReasoningTagsFromText(testCase.input, testCase.opts)).toBe(testCase.expected); + } }); }); });