test(config): consolidate env/include scenario coverage
This commit is contained in:
@@ -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<string, string>;
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).polluted).toBeUndefined();
|
||||
expect(result).toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -54,145 +54,171 @@ describe("stripReasoningTagsFromText", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("handles mixed real tags and code tags", () => {
|
||||
const input = "<think>hidden</think>Visible text with `<think>` example.";
|
||||
expect(stripReasoningTagsFromText(input)).toBe("Visible text with `<think>` example.");
|
||||
});
|
||||
|
||||
it("handles code block followed by real tags", () => {
|
||||
const input = "```\n<think>code</think>\n```\n<think>real hidden</think>visible";
|
||||
expect(stripReasoningTagsFromText(input)).toBe("```\n<think>code</think>\n```\nvisible");
|
||||
it("handles mixed code-tag and real-tag content", () => {
|
||||
const cases = [
|
||||
{
|
||||
input: "<think>hidden</think>Visible text with `<think>` example.",
|
||||
expected: "Visible text with `<think>` example.",
|
||||
},
|
||||
{
|
||||
input: "```\n<think>code</think>\n```\n<think>real hidden</think>visible",
|
||||
expected: "```\n<think>code</think>\n```\nvisible",
|
||||
},
|
||||
] as const;
|
||||
for (const { input, expected } of cases) {
|
||||
expect(stripReasoningTagsFromText(input)).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("preserves unclosed <think without angle bracket", () => {
|
||||
const input = "Here is how to use <think tags in your code";
|
||||
expect(stripReasoningTagsFromText(input)).toBe(input);
|
||||
it("handles malformed tags and null-ish inputs", () => {
|
||||
const cases = [
|
||||
{
|
||||
input: "Here is how to use <think tags in your code",
|
||||
expected: "Here is how to use <think tags in your code",
|
||||
},
|
||||
{
|
||||
input: "You can start with <think and then close with </think>",
|
||||
expected: "You can start with <think and then close with",
|
||||
},
|
||||
{
|
||||
input: "A < think >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 <think and then close with </think>";
|
||||
expect(stripReasoningTagsFromText(input)).toBe(
|
||||
"You can start with <think and then close with",
|
||||
);
|
||||
it("handles fenced and inline code edge behavior", () => {
|
||||
const cases = [
|
||||
{
|
||||
input: "Example:\n~~~\n<think>reasoning</think>\n~~~\nDone!",
|
||||
expected: "Example:\n~~~\n<think>reasoning</think>\n~~~\nDone!",
|
||||
},
|
||||
{
|
||||
input: "Example:\n~~~js\n<think>code</think>\n~~~",
|
||||
expected: "Example:\n~~~js\n<think>code</think>\n~~~",
|
||||
},
|
||||
{
|
||||
input: "Use ``code`` with <think>hidden</think> text",
|
||||
expected: "Use ``code`` with text",
|
||||
},
|
||||
{
|
||||
input: "Before\n```\ncode\n```\nAfter with <think>hidden</think>",
|
||||
expected: "Before\n```\ncode\n```\nAfter with",
|
||||
},
|
||||
{
|
||||
input: "```\n<think>not protected\n~~~\n</think>text",
|
||||
expected: "```\n<think>not protected\n~~~\n</think>text",
|
||||
},
|
||||
{
|
||||
input: "Start `unclosed <think>hidden</think> 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: "<think>outer <think>inner</think> still outer</think>visible",
|
||||
expected: "still outervisible",
|
||||
},
|
||||
{
|
||||
input: "A<final>1</final>B<final>2</final>C",
|
||||
expected: "A1B2C",
|
||||
},
|
||||
{
|
||||
input: "`<final>` in code, <final>visible</final> outside",
|
||||
expected: "`<final>` 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: "你好 <think>思考 🤔</think> 世界",
|
||||
expected: "你好 世界",
|
||||
},
|
||||
{
|
||||
input: "A <think id='test' class=\"foo\">hidden</think> B",
|
||||
expected: "A B",
|
||||
},
|
||||
{
|
||||
input: "A <THINK>hidden</THINK> <Thinking>also hidden</Thinking> 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~~~\n<think>reasoning</think>\n~~~\nDone!";
|
||||
expect(stripReasoningTagsFromText(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("preserves tags in tilde block at EOF without trailing newline", () => {
|
||||
const input = "Example:\n~~~js\n<think>code</think>\n~~~";
|
||||
expect(stripReasoningTagsFromText(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("handles nested think patterns (first close ends block)", () => {
|
||||
const input = "<think>outer <think>inner</think> still outer</think>visible";
|
||||
expect(stripReasoningTagsFromText(input)).toBe("still outervisible");
|
||||
});
|
||||
|
||||
it("strips final tag markup but preserves content (by design)", () => {
|
||||
const input = "A<final>1</final>B<final>2</final>C";
|
||||
expect(stripReasoningTagsFromText(input)).toBe("A1B2C");
|
||||
});
|
||||
|
||||
it("preserves final tags in inline code (markup only stripped outside)", () => {
|
||||
const input = "`<final>` in code, <final>visible</final> outside";
|
||||
expect(stripReasoningTagsFromText(input)).toBe("`<final>` in code, visible outside");
|
||||
});
|
||||
|
||||
it("handles double backtick inline code with tags", () => {
|
||||
const input = "Use ``code`` with <think>hidden</think> text";
|
||||
expect(stripReasoningTagsFromText(input)).toBe("Use ``code`` with text");
|
||||
});
|
||||
|
||||
it("handles fenced code blocks with content", () => {
|
||||
const input = "Before\n```\ncode\n```\nAfter with <think>hidden</think>";
|
||||
expect(stripReasoningTagsFromText(input)).toBe("Before\n```\ncode\n```\nAfter with");
|
||||
});
|
||||
|
||||
it("does not match mismatched fence types (``` vs ~~~)", () => {
|
||||
const input = "```\n<think>not protected\n~~~\n</think>text";
|
||||
const result = stripReasoningTagsFromText(input);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it("handles unicode content inside and around tags", () => {
|
||||
const input = "你好 <think>思考 🤔</think> 世界";
|
||||
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 = `<think>${longContent}</think>visible`;
|
||||
expect(stripReasoningTagsFromText(input)).toBe("visible");
|
||||
});
|
||||
expect(stripReasoningTagsFromText(`<think>${longContent}</think>visible`)).toBe("visible");
|
||||
|
||||
it("handles tags with attributes", () => {
|
||||
const input = "A <think id='test' class=\"foo\">hidden</think> B";
|
||||
expect(stripReasoningTagsFromText(input)).toBe("A B");
|
||||
});
|
||||
|
||||
it("is case-insensitive for tag names", () => {
|
||||
const input = "A <THINK>hidden</THINK> <Thinking>also hidden</Thinking> B";
|
||||
expect(stripReasoningTagsFromText(input)).toBe("A B");
|
||||
});
|
||||
|
||||
it("handles pathological nested backtick patterns without hanging", () => {
|
||||
const input = "`".repeat(100) + "<think>test</think>" + "`".repeat(100);
|
||||
const pathological = "`".repeat(100) + "<think>test</think>" + "`".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 <think>hidden</think> 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 <think>unclosed content after";
|
||||
expect(stripReasoningTagsFromText(input, { mode: "strict" })).toBe("Before");
|
||||
});
|
||||
|
||||
it("preserve mode keeps content after unclosed tag", () => {
|
||||
const input = "Before <think>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 = " <think>x</think> result <think>y</think> ";
|
||||
expect(stripReasoningTagsFromText(input)).toBe("result");
|
||||
});
|
||||
|
||||
it("trim=none preserves whitespace", () => {
|
||||
const input = " <think>x</think> result ";
|
||||
expect(stripReasoningTagsFromText(input, { trim: "none" })).toBe(" result ");
|
||||
});
|
||||
|
||||
it("trim=start only trims start", () => {
|
||||
const input = " <think>x</think> result ";
|
||||
expect(stripReasoningTagsFromText(input, { trim: "start" })).toBe("result ");
|
||||
it("applies configured trim strategies", () => {
|
||||
const cases = [
|
||||
{
|
||||
input: " <think>x</think> result <think>y</think> ",
|
||||
expected: "result",
|
||||
opts: undefined,
|
||||
},
|
||||
{
|
||||
input: " <think>x</think> result ",
|
||||
expected: " result ",
|
||||
opts: { trim: "none" as const },
|
||||
},
|
||||
{
|
||||
input: " <think>x</think> result ",
|
||||
expected: "result ",
|
||||
opts: { trim: "start" as const },
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(stripReasoningTagsFromText(testCase.input, testCase.opts)).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user