test: fix includes tests on windows

This commit is contained in:
Peter Steinberger
2026-01-12 00:39:08 +00:00
parent 376d007371
commit cb095c8606

View File

@@ -1,3 +1,5 @@
import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
@@ -7,6 +9,25 @@ import {
resolveConfigIncludes, resolveConfigIncludes,
} from "./includes.js"; } from "./includes.js";
const ROOT_DIR = path.parse(process.cwd()).root;
const CONFIG_DIR = path.join(ROOT_DIR, "config");
const ETC_CLAWDBOT_DIR = path.join(ROOT_DIR, "etc", "clawdbot");
const SHARED_DIR = path.join(ROOT_DIR, "shared");
const DEFAULT_BASE_PATH = path.join(CONFIG_DIR, "clawdbot.json");
function configPath(...parts: string[]) {
return path.join(CONFIG_DIR, ...parts);
}
function etcClawdbotPath(...parts: string[]) {
return path.join(ETC_CLAWDBOT_DIR, ...parts);
}
function sharedPath(...parts: string[]) {
return path.join(SHARED_DIR, ...parts);
}
function createMockResolver(files: Record<string, unknown>): IncludeResolver { function createMockResolver(files: Record<string, unknown>): IncludeResolver {
return { return {
readFile: (filePath: string) => { readFile: (filePath: string) => {
@@ -22,7 +43,7 @@ function createMockResolver(files: Record<string, unknown>): IncludeResolver {
function resolve( function resolve(
obj: unknown, obj: unknown,
files: Record<string, unknown> = {}, files: Record<string, unknown> = {},
basePath = "/config/clawdbot.json", basePath = DEFAULT_BASE_PATH,
) { ) {
return resolveConfigIncludes(obj, basePath, createMockResolver(files)); return resolveConfigIncludes(obj, basePath, createMockResolver(files));
} }
@@ -45,7 +66,7 @@ describe("resolveConfigIncludes", () => {
}); });
it("resolves single file $include", () => { it("resolves single file $include", () => {
const files = { "/config/agents.json": { list: [{ id: "main" }] } }; const files = { [configPath("agents.json")]: { list: [{ id: "main" }] } };
const obj = { agents: { $include: "./agents.json" } }; const obj = { agents: { $include: "./agents.json" } };
expect(resolve(obj, files)).toEqual({ expect(resolve(obj, files)).toEqual({
agents: { list: [{ id: "main" }] }, agents: { list: [{ id: "main" }] },
@@ -53,8 +74,9 @@ describe("resolveConfigIncludes", () => {
}); });
it("resolves absolute path $include", () => { it("resolves absolute path $include", () => {
const files = { "/etc/clawdbot/agents.json": { list: [{ id: "main" }] } }; const absolute = etcClawdbotPath("agents.json");
const obj = { agents: { $include: "/etc/clawdbot/agents.json" } }; const files = { [absolute]: { list: [{ id: "main" }] } };
const obj = { agents: { $include: absolute } };
expect(resolve(obj, files)).toEqual({ expect(resolve(obj, files)).toEqual({
agents: { list: [{ id: "main" }] }, agents: { list: [{ id: "main" }] },
}); });
@@ -62,8 +84,8 @@ describe("resolveConfigIncludes", () => {
it("resolves array $include with deep merge", () => { it("resolves array $include with deep merge", () => {
const files = { const files = {
"/config/a.json": { "group-a": ["agent1"] }, [configPath("a.json")]: { "group-a": ["agent1"] },
"/config/b.json": { "group-b": ["agent2"] }, [configPath("b.json")]: { "group-b": ["agent2"] },
}; };
const obj = { broadcast: { $include: ["./a.json", "./b.json"] } }; const obj = { broadcast: { $include: ["./a.json", "./b.json"] } };
expect(resolve(obj, files)).toEqual({ expect(resolve(obj, files)).toEqual({
@@ -76,8 +98,8 @@ describe("resolveConfigIncludes", () => {
it("deep merges overlapping keys in array $include", () => { it("deep merges overlapping keys in array $include", () => {
const files = { const files = {
"/config/a.json": { agents: { defaults: { workspace: "~/a" } } }, [configPath("a.json")]: { agents: { defaults: { workspace: "~/a" } } },
"/config/b.json": { agents: { list: [{ id: "main" }] } }, [configPath("b.json")]: { agents: { list: [{ id: "main" }] } },
}; };
const obj = { $include: ["./a.json", "./b.json"] }; const obj = { $include: ["./a.json", "./b.json"] };
expect(resolve(obj, files)).toEqual({ expect(resolve(obj, files)).toEqual({
@@ -89,19 +111,19 @@ describe("resolveConfigIncludes", () => {
}); });
it("merges $include with sibling keys", () => { it("merges $include with sibling keys", () => {
const files = { "/config/base.json": { a: 1, b: 2 } }; const files = { [configPath("base.json")]: { a: 1, b: 2 } };
const obj = { $include: "./base.json", c: 3 }; const obj = { $include: "./base.json", c: 3 };
expect(resolve(obj, files)).toEqual({ a: 1, b: 2, c: 3 }); expect(resolve(obj, files)).toEqual({ a: 1, b: 2, c: 3 });
}); });
it("sibling keys override included values", () => { it("sibling keys override included values", () => {
const files = { "/config/base.json": { a: 1, b: 2 } }; const files = { [configPath("base.json")]: { a: 1, b: 2 } };
const obj = { $include: "./base.json", b: 99 }; const obj = { $include: "./base.json", b: 99 };
expect(resolve(obj, files)).toEqual({ a: 1, b: 99 }); expect(resolve(obj, files)).toEqual({ a: 1, b: 99 });
}); });
it("throws when sibling keys are used with non-object includes", () => { it("throws when sibling keys are used with non-object includes", () => {
const files = { "/config/list.json": ["a", "b"] }; const files = { [configPath("list.json")]: ["a", "b"] };
const obj = { $include: "./list.json", extra: true }; const obj = { $include: "./list.json", extra: true };
expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); expect(() => resolve(obj, files)).toThrow(ConfigIncludeError);
expect(() => resolve(obj, files)).toThrow( expect(() => resolve(obj, files)).toThrow(
@@ -110,7 +132,7 @@ describe("resolveConfigIncludes", () => {
}); });
it("throws when sibling keys are used with primitive includes", () => { it("throws when sibling keys are used with primitive includes", () => {
const files = { "/config/value.json": "hello" }; const files = { [configPath("value.json")]: "hello" };
const obj = { $include: "./value.json", extra: true }; const obj = { $include: "./value.json", extra: true };
expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); expect(() => resolve(obj, files)).toThrow(ConfigIncludeError);
expect(() => resolve(obj, files)).toThrow( expect(() => resolve(obj, files)).toThrow(
@@ -120,8 +142,8 @@ describe("resolveConfigIncludes", () => {
it("resolves nested includes", () => { it("resolves nested includes", () => {
const files = { const files = {
"/config/level1.json": { nested: { $include: "./level2.json" } }, [configPath("level1.json")]: { nested: { $include: "./level2.json" } },
"/config/level2.json": { deep: "value" }, [configPath("level2.json")]: { deep: "value" },
}; };
const obj = { $include: "./level1.json" }; const obj = { $include: "./level1.json" };
expect(resolve(obj, files)).toEqual({ expect(resolve(obj, files)).toEqual({
@@ -142,20 +164,22 @@ describe("resolveConfigIncludes", () => {
}; };
const obj = { $include: "./bad.json" }; const obj = { $include: "./bad.json" };
expect(() => expect(() =>
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver), resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver),
).toThrow(ConfigIncludeError); ).toThrow(ConfigIncludeError);
expect(() => expect(() =>
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver), resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver),
).toThrow(/Failed to parse include file/); ).toThrow(/Failed to parse include file/);
}); });
it("throws CircularIncludeError for circular includes", () => { it("throws CircularIncludeError for circular includes", () => {
const aPath = configPath("a.json");
const bPath = configPath("b.json");
const resolver: IncludeResolver = { const resolver: IncludeResolver = {
readFile: (filePath: string) => { readFile: (filePath: string) => {
if (filePath === "/config/a.json") { if (filePath === aPath) {
return JSON.stringify({ $include: "./b.json" }); return JSON.stringify({ $include: "./b.json" });
} }
if (filePath === "/config/b.json") { if (filePath === bPath) {
return JSON.stringify({ $include: "./a.json" }); return JSON.stringify({ $include: "./a.json" });
} }
throw new Error(`Unknown file: ${filePath}`); throw new Error(`Unknown file: ${filePath}`);
@@ -164,21 +188,17 @@ describe("resolveConfigIncludes", () => {
}; };
const obj = { $include: "./a.json" }; const obj = { $include: "./a.json" };
try { try {
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver); resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver);
throw new Error("expected circular include error"); throw new Error("expected circular include error");
} catch (err) { } catch (err) {
expect(err).toBeInstanceOf(CircularIncludeError); expect(err).toBeInstanceOf(CircularIncludeError);
const circular = err as CircularIncludeError; const circular = err as CircularIncludeError;
expect(circular.chain).toEqual( expect(circular.chain).toEqual(
expect.arrayContaining([ expect.arrayContaining([DEFAULT_BASE_PATH, aPath, bPath]),
"/config/clawdbot.json",
"/config/a.json",
"/config/b.json",
]),
); );
expect(circular.message).toMatch(/Circular include detected/); expect(circular.message).toMatch(/Circular include detected/);
expect(circular.message).toMatch(/\/config\/a\.json/); expect(circular.message).toContain("a.json");
expect(circular.message).toMatch(/\/config\/b\.json/); expect(circular.message).toContain("b.json");
} }
}); });
@@ -189,14 +209,14 @@ describe("resolveConfigIncludes", () => {
}); });
it("throws ConfigIncludeError for invalid array item type", () => { it("throws ConfigIncludeError for invalid array item type", () => {
const files = { "/config/valid.json": { valid: true } }; const files = { [configPath("valid.json")]: { valid: true } };
const obj = { $include: ["./valid.json", 123] }; const obj = { $include: ["./valid.json", 123] };
expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); expect(() => resolve(obj, files)).toThrow(ConfigIncludeError);
expect(() => resolve(obj, files)).toThrow(/expected string, got number/); expect(() => resolve(obj, files)).toThrow(/expected string, got number/);
}); });
it("throws ConfigIncludeError for null/boolean include items", () => { it("throws ConfigIncludeError for null/boolean include items", () => {
const files = { "/config/valid.json": { valid: true } }; const files = { [configPath("valid.json")]: { valid: true } };
const cases = [ const cases = [
{ value: null, expected: "object" }, { value: null, expected: "object" },
{ value: false, expected: "boolean" }, { value: false, expected: "boolean" },
@@ -213,9 +233,11 @@ describe("resolveConfigIncludes", () => {
it("respects max depth limit", () => { it("respects max depth limit", () => {
const files: Record<string, unknown> = {}; const files: Record<string, unknown> = {};
for (let i = 0; i < 15; i++) { for (let i = 0; i < 15; i++) {
files[`/config/level${i}.json`] = { $include: `./level${i + 1}.json` }; files[configPath(`level${i}.json`)] = {
$include: `./level${i + 1}.json`,
};
} }
files["/config/level15.json"] = { done: true }; files[configPath("level15.json")] = { done: true };
const obj = { $include: "./level0.json" }; const obj = { $include: "./level0.json" };
expect(() => resolve(obj, files)).toThrow(ConfigIncludeError); expect(() => resolve(obj, files)).toThrow(ConfigIncludeError);
@@ -225,18 +247,20 @@ describe("resolveConfigIncludes", () => {
it("allows depth 10 but rejects depth 11", () => { it("allows depth 10 but rejects depth 11", () => {
const okFiles: Record<string, unknown> = {}; const okFiles: Record<string, unknown> = {};
for (let i = 0; i < 9; i++) { for (let i = 0; i < 9; i++) {
okFiles[`/config/ok${i}.json`] = { $include: `./ok${i + 1}.json` }; okFiles[configPath(`ok${i}.json`)] = { $include: `./ok${i + 1}.json` };
} }
okFiles["/config/ok9.json"] = { done: true }; okFiles[configPath("ok9.json")] = { done: true };
expect(resolve({ $include: "./ok0.json" }, okFiles)).toEqual({ expect(resolve({ $include: "./ok0.json" }, okFiles)).toEqual({
done: true, done: true,
}); });
const failFiles: Record<string, unknown> = {}; const failFiles: Record<string, unknown> = {};
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
failFiles[`/config/fail${i}.json`] = { $include: `./fail${i + 1}.json` }; failFiles[configPath(`fail${i}.json`)] = {
$include: `./fail${i + 1}.json`,
};
} }
failFiles["/config/fail10.json"] = { done: true }; failFiles[configPath("fail10.json")] = { done: true };
expect(() => resolve({ $include: "./fail0.json" }, failFiles)).toThrow( expect(() => resolve({ $include: "./fail0.json" }, failFiles)).toThrow(
ConfigIncludeError, ConfigIncludeError,
); );
@@ -246,7 +270,9 @@ describe("resolveConfigIncludes", () => {
}); });
it("handles relative paths correctly", () => { it("handles relative paths correctly", () => {
const files = { "/config/clients/mueller/agents.json": { id: "mueller" } }; const files = {
[configPath("clients", "mueller", "agents.json")]: { id: "mueller" },
};
const obj = { agent: { $include: "./clients/mueller/agents.json" } }; const obj = { agent: { $include: "./clients/mueller/agents.json" } };
expect(resolve(obj, files)).toEqual({ expect(resolve(obj, files)).toEqual({
agent: { id: "mueller" }, agent: { id: "mueller" },
@@ -255,8 +281,8 @@ describe("resolveConfigIncludes", () => {
it("applies nested includes before sibling overrides", () => { it("applies nested includes before sibling overrides", () => {
const files = { const files = {
"/config/base.json": { nested: { $include: "./nested.json" } }, [configPath("base.json")]: { nested: { $include: "./nested.json" } },
"/config/nested.json": { a: 1, b: 2 }, [configPath("nested.json")]: { a: 1, b: 2 },
}; };
const obj = { $include: "./base.json", nested: { b: 9 } }; const obj = { $include: "./base.json", nested: { b: 9 } };
expect(resolve(obj, files)).toEqual({ expect(resolve(obj, files)).toEqual({
@@ -265,9 +291,9 @@ describe("resolveConfigIncludes", () => {
}); });
it("resolves parent directory references", () => { it("resolves parent directory references", () => {
const files = { "/shared/common.json": { shared: true } }; const files = { [sharedPath("common.json")]: { shared: true } };
const obj = { $include: "../../shared/common.json" }; const obj = { $include: "../../shared/common.json" };
expect(resolve(obj, files, "/config/sub/clawdbot.json")).toEqual({ expect(resolve(obj, files, configPath("sub", "clawdbot.json"))).toEqual({
shared: true, shared: true,
}); });
}); });
@@ -276,7 +302,7 @@ describe("resolveConfigIncludes", () => {
describe("real-world config patterns", () => { describe("real-world config patterns", () => {
it("supports per-client agent includes", () => { it("supports per-client agent includes", () => {
const files = { const files = {
"/config/clients/mueller.json": { [configPath("clients", "mueller.json")]: {
agents: [ agents: [
{ {
id: "mueller-screenshot", id: "mueller-screenshot",
@@ -291,7 +317,7 @@ describe("real-world config patterns", () => {
"group-mueller": ["mueller-screenshot", "mueller-transcribe"], "group-mueller": ["mueller-screenshot", "mueller-transcribe"],
}, },
}, },
"/config/clients/schmidt.json": { [configPath("clients", "schmidt.json")]: {
agents: [ agents: [
{ {
id: "schmidt-screenshot", id: "schmidt-screenshot",
@@ -323,11 +349,13 @@ describe("real-world config patterns", () => {
it("supports modular config structure", () => { it("supports modular config structure", () => {
const files = { const files = {
"/config/gateway.json": { gateway: { port: 18789, bind: "loopback" } }, [configPath("gateway.json")]: {
"/config/providers/whatsapp.json": { gateway: { port: 18789, bind: "loopback" },
},
[configPath("providers", "whatsapp.json")]: {
whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] }, whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] },
}, },
"/config/agents/defaults.json": { [configPath("agents", "defaults.json")]: {
agents: { defaults: { sandbox: { mode: "all" } } }, agents: { defaults: { sandbox: { mode: "all" } } },
}, },
}; };