295 lines
9.3 KiB
TypeScript
295 lines
9.3 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
import { loadConfig, findProjectRoot, getRuneDir, validateConfig } from "../../src/core/config.ts";
|
||
import { ConfigError } from "../../src/cli/errors.ts";
|
||
import type { RuneConfig } from "../../src/types.ts";
|
||
|
||
const TMP_DIR = join(import.meta.dir, "__tmp_config_test__");
|
||
|
||
beforeEach(async () => {
|
||
await mkdir(TMP_DIR, { recursive: true });
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await rm(TMP_DIR, { recursive: true, force: true });
|
||
});
|
||
|
||
describe("findProjectRoot", () => {
|
||
it("当前目录存在 .rune/ 时返回当前目录", async () => {
|
||
await mkdir(join(TMP_DIR, ".rune"));
|
||
expect(findProjectRoot(TMP_DIR)).toBe(TMP_DIR);
|
||
});
|
||
|
||
it("子目录中向上查找到 .rune/", async () => {
|
||
await mkdir(join(TMP_DIR, ".rune"));
|
||
const sub = join(TMP_DIR, "src", "commands");
|
||
await mkdir(sub, { recursive: true });
|
||
expect(findProjectRoot(sub)).toBe(TMP_DIR);
|
||
});
|
||
|
||
it("找不到 .rune/ 时返回 null", () => {
|
||
expect(findProjectRoot("/tmp/__nonexistent__")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("loadConfig", () => {
|
||
it("无配置文件时返回默认配置", async () => {
|
||
await mkdir(join(TMP_DIR, ".rune"));
|
||
const config = await loadConfig(TMP_DIR);
|
||
expect(config.stages.discuss).toBeDefined();
|
||
expect(config.stages.plan).toBeDefined();
|
||
expect(config.stages.build).toBeDefined();
|
||
expect(config.stages.archive).toBeDefined();
|
||
});
|
||
|
||
it("用户配置覆盖默认配置(全量替换)", async () => {
|
||
const runeDir = join(TMP_DIR, ".rune");
|
||
await mkdir(runeDir, { recursive: true });
|
||
await writeFile(
|
||
join(runeDir, "config.yaml"),
|
||
`stages:
|
||
discuss:
|
||
prompt: 自定义讨论提示词
|
||
`,
|
||
);
|
||
const config = await loadConfig(TMP_DIR);
|
||
expect(config.stages.discuss!.prompt).toBe("自定义讨论提示词");
|
||
expect(config.stages.plan).toBeDefined();
|
||
expect(config.stages.build).toBeDefined();
|
||
expect(config.stages.archive).toBeDefined();
|
||
});
|
||
|
||
it("用户配置 plan 时完全替换内置 documents", async () => {
|
||
const runeDir = join(TMP_DIR, ".rune");
|
||
await mkdir(runeDir, { recursive: true });
|
||
await writeFile(
|
||
join(runeDir, "config.yaml"),
|
||
`metadata:
|
||
tracked: false
|
||
stages:
|
||
plan:
|
||
documents:
|
||
- name: spec
|
||
prompt: 生成规格文档
|
||
`,
|
||
);
|
||
const config = await loadConfig(TMP_DIR);
|
||
expect(config.stages.plan!.documents).toHaveLength(1);
|
||
expect(config.stages.plan!.documents[0].name).toBe("spec");
|
||
});
|
||
|
||
it("空配置 stages: {} 时返回完整默认配置", async () => {
|
||
const runeDir = join(TMP_DIR, ".rune");
|
||
await mkdir(runeDir, { recursive: true });
|
||
await writeFile(join(runeDir, "config.yaml"), "stages: {}");
|
||
const config = await loadConfig(TMP_DIR);
|
||
expect(config.stages.discuss).toBeDefined();
|
||
expect(config.stages.plan).toBeDefined();
|
||
expect(config.stages.build).toBeDefined();
|
||
expect(config.stages.archive).toBeDefined();
|
||
});
|
||
|
||
it("YAML 解析错误时返回默认配置", async () => {
|
||
const runeDir = join(TMP_DIR, ".rune");
|
||
await mkdir(runeDir, { recursive: true });
|
||
await writeFile(join(runeDir, "config.yaml"), `stages: [invalid yaml {{{`);
|
||
const config = await loadConfig(TMP_DIR);
|
||
expect(config.stages.discuss).toBeDefined();
|
||
});
|
||
});
|
||
|
||
describe("getRuneDir", () => {
|
||
it("返回 .rune 目录路径", () => {
|
||
expect(getRuneDir("/project")).toBe(join("/project", ".rune"));
|
||
});
|
||
});
|
||
|
||
describe("validateConfig", () => {
|
||
it("正常配置不抛错", () => {
|
||
const config: RuneConfig = {
|
||
stages: {
|
||
plan: {
|
||
documents: [
|
||
{ name: "design", prompt: "生成设计" },
|
||
{ name: "task", prompt: "生成任务", depend: ["design"] },
|
||
],
|
||
},
|
||
},
|
||
};
|
||
expect(() => validateConfig(config)).not.toThrow();
|
||
});
|
||
|
||
it("depend 引用不存在的文档时报错", () => {
|
||
const config: RuneConfig = {
|
||
stages: {
|
||
plan: {
|
||
documents: [{ name: "task", prompt: "生成任务", depend: ["nonexistent"] }],
|
||
},
|
||
},
|
||
};
|
||
expect(() => validateConfig(config)).toThrow(ConfigError);
|
||
});
|
||
|
||
it("自依赖报错", () => {
|
||
const config: RuneConfig = {
|
||
stages: {
|
||
plan: {
|
||
documents: [{ name: "design", prompt: "生成设计", depend: ["design"] }],
|
||
},
|
||
},
|
||
};
|
||
expect(() => validateConfig(config)).toThrow(ConfigError);
|
||
});
|
||
|
||
it("循环依赖报错", () => {
|
||
const config: RuneConfig = {
|
||
stages: {
|
||
plan: {
|
||
documents: [
|
||
{ name: "a", prompt: "a", depend: ["b"] },
|
||
{ name: "b", prompt: "b", depend: ["a"] },
|
||
],
|
||
},
|
||
},
|
||
};
|
||
expect(() => validateConfig(config)).toThrow(ConfigError);
|
||
});
|
||
|
||
it("无 plan 阶段时不报错", () => {
|
||
const config: RuneConfig = { stages: {} };
|
||
expect(() => validateConfig(config)).not.toThrow();
|
||
});
|
||
|
||
it("depend 为空数组时不报错", () => {
|
||
const config: RuneConfig = {
|
||
stages: {
|
||
plan: {
|
||
documents: [{ name: "design", prompt: "生成设计", depend: [] }],
|
||
},
|
||
},
|
||
};
|
||
expect(() => validateConfig(config)).not.toThrow();
|
||
});
|
||
|
||
it("tracked=true 时 plan.documents 必须包含 task 文档", () => {
|
||
const config: RuneConfig = {
|
||
stages: {
|
||
plan: {
|
||
documents: [{ name: "design", prompt: "生成设计" }],
|
||
},
|
||
},
|
||
metadata: { tracked: true },
|
||
};
|
||
expect(() => validateConfig(config)).toThrow(ConfigError);
|
||
});
|
||
|
||
it("tracked=true 且 plan.documents 包含 task 时不报错", () => {
|
||
const config: RuneConfig = {
|
||
stages: {
|
||
plan: {
|
||
documents: [
|
||
{ name: "design", prompt: "生成设计" },
|
||
{ name: "task", prompt: "生成任务" },
|
||
],
|
||
},
|
||
},
|
||
metadata: { tracked: true },
|
||
};
|
||
expect(() => validateConfig(config)).not.toThrow();
|
||
});
|
||
|
||
it("tracked=false 时 plan.documents 不包含 task 也不报错", () => {
|
||
const config: RuneConfig = {
|
||
stages: {
|
||
plan: {
|
||
documents: [{ name: "design", prompt: "生成设计" }],
|
||
},
|
||
},
|
||
metadata: { tracked: false },
|
||
};
|
||
expect(() => validateConfig(config)).not.toThrow();
|
||
});
|
||
|
||
it("tracked 未配置时等同于 false,不强制要求 task 文档", () => {
|
||
const config: RuneConfig = {
|
||
stages: {
|
||
plan: {
|
||
documents: [{ name: "design", prompt: "生成设计" }],
|
||
},
|
||
},
|
||
};
|
||
expect(() => validateConfig(config)).not.toThrow();
|
||
});
|
||
});
|
||
|
||
describe("mergeConfig 保留 metadata", () => {
|
||
it("保留用户配置中的 metadata", async () => {
|
||
const tmpDir = join(import.meta.dir, "__tmp_config_meta_test__");
|
||
await mkdir(tmpDir, { recursive: true });
|
||
try {
|
||
const configPath = join(tmpDir, ".rune", "config.yaml");
|
||
await mkdir(join(tmpDir, ".rune"), { recursive: true });
|
||
await writeFile(
|
||
configPath,
|
||
`metadata:\n command: "bunx @lanyuanxiaoyao/rune"\nstages:\n discuss:\n prompt: "自定义讨论"\n`,
|
||
);
|
||
const config = await loadConfig(tmpDir);
|
||
expect(config.metadata).toBeDefined();
|
||
expect(config.metadata!.command).toBe("bunx @lanyuanxiaoyao/rune");
|
||
expect(config.stages.discuss!.prompt).toBe("自定义讨论");
|
||
} finally {
|
||
await rm(tmpDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("无 metadata 时保留默认 metadata(tracked: true)", async () => {
|
||
const tmpDir = join(import.meta.dir, "__tmp_config_nometa_test__");
|
||
await mkdir(tmpDir, { recursive: true });
|
||
try {
|
||
const configPath = join(tmpDir, ".rune", "config.yaml");
|
||
await mkdir(join(tmpDir, ".rune"), { recursive: true });
|
||
await writeFile(configPath, `stages:\n discuss:\n prompt: "测试"\n`);
|
||
const config = await loadConfig(tmpDir);
|
||
expect(config.metadata?.tracked).toBe(true);
|
||
} finally {
|
||
await rm(tmpDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("用户 metadata 与默认 metadata 深合并", async () => {
|
||
const tmpDir = join(import.meta.dir, "__tmp_config_deep_merge__");
|
||
await mkdir(tmpDir, { recursive: true });
|
||
try {
|
||
const configPath = join(tmpDir, ".rune", "config.yaml");
|
||
await mkdir(join(tmpDir, ".rune"), { recursive: true });
|
||
await writeFile(
|
||
configPath,
|
||
`metadata:\n command: "rune"\nstages:\n discuss:\n prompt: "测试"\n`,
|
||
);
|
||
const config = await loadConfig(tmpDir);
|
||
expect(config.metadata?.command).toBe("rune");
|
||
expect(config.metadata?.tracked).toBe(true);
|
||
} finally {
|
||
await rm(tmpDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("用户 metadata.tracked 显式覆盖默认值", async () => {
|
||
const tmpDir = join(import.meta.dir, "__tmp_config_tracked_override__");
|
||
await mkdir(tmpDir, { recursive: true });
|
||
try {
|
||
const configPath = join(tmpDir, ".rune", "config.yaml");
|
||
await mkdir(join(tmpDir, ".rune"), { recursive: true });
|
||
await writeFile(
|
||
configPath,
|
||
`metadata:\n tracked: false\nstages:\n discuss:\n prompt: "测试"\n`,
|
||
);
|
||
const config = await loadConfig(tmpDir);
|
||
expect(config.metadata?.tracked).toBe(false);
|
||
} finally {
|
||
await rm(tmpDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|