feat: 第二期 — Tier 2 场景级 mock + 错误/流程/依赖测试
This commit is contained in:
178
tests/agent/e2e-depend.test.ts
Normal file
178
tests/agent/e2e-depend.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { createRunner } from "./tier1-command.ts";
|
||||
import { ScenarioRunner, type BuildOverride } from "./tier2-scenario.ts";
|
||||
import {
|
||||
setupTempDir,
|
||||
cleanupTempDir,
|
||||
getTempDir,
|
||||
createFreshProject,
|
||||
writeDoc,
|
||||
} from "./fixtures.ts";
|
||||
import { scanChanges } from "../../src/core/scanner.ts";
|
||||
import { validateConfig } from "../../src/core/config.ts";
|
||||
import { ConfigError } from "../../src/cli/errors.ts";
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
|
||||
describe("e2e: 文档依赖", () => {
|
||||
let runner: ReturnType<typeof createRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTempDir();
|
||||
runner = createRunner();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempDir();
|
||||
});
|
||||
|
||||
it("依赖文档按顺序创建(A → B → C 链式依赖)", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "a", prompt: "文档 A" },
|
||||
{ name: "b", prompt: "文档 B", depend: ["a"] },
|
||||
{ name: "c", prompt: "文档 C", depend: ["b"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await createFreshProject();
|
||||
await runner.runPlan(getTempDir(), "chain", "a", config);
|
||||
|
||||
let changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
|
||||
const chain = changes[0]!;
|
||||
const docsA = chain.documents;
|
||||
const aDoc = docsA.find((d) => d.name === "a")!;
|
||||
const bDoc = docsA.find((d) => d.name === "b")!;
|
||||
const cDoc = docsA.find((d) => d.name === "c")!;
|
||||
|
||||
expect(aDoc.completed).toBe(true);
|
||||
expect(aDoc.dependMet).toBe(true);
|
||||
expect(bDoc.completed).toBe(false);
|
||||
// a.md 已存在,所以 b 的依赖已满足
|
||||
expect(bDoc.dependMet).toBe(true);
|
||||
expect(cDoc.completed).toBe(false);
|
||||
// c 依赖 b,b.md 不存在,所以 dependMet=false
|
||||
expect(cDoc.dependMet).toBe(false);
|
||||
expect(chain.planCompleted).toBe(false);
|
||||
|
||||
await runner.runPlan(getTempDir(), "chain", "b", config);
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
const chain2 = changes[0]!;
|
||||
const docsB = chain2.documents;
|
||||
const bDoc2 = docsB.find((d) => d.name === "b")!;
|
||||
const cDoc2 = docsB.find((d) => d.name === "c")!;
|
||||
|
||||
expect(bDoc2.completed).toBe(true);
|
||||
expect(bDoc2.dependMet).toBe(true);
|
||||
expect(cDoc2.completed).toBe(false);
|
||||
// b.md 已存在,c 的依赖现在满足
|
||||
expect(cDoc2.dependMet).toBe(true);
|
||||
expect(chain2.planCompleted).toBe(false);
|
||||
|
||||
await runner.runPlan(getTempDir(), "chain", "c", config);
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
const chain3 = changes[0]!;
|
||||
const docsC = chain3.documents;
|
||||
|
||||
expect(docsC.every((d) => d.completed)).toBe(true);
|
||||
expect(docsC.every((d) => d.dependMet)).toBe(true);
|
||||
expect(chain3.planCompleted).toBe(true);
|
||||
expect(chain3.buildUnlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("引用不存在文档的依赖被校验拒绝", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [{ name: "design", prompt: "设计", depend: ["ghost"] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
validateConfig(config);
|
||||
// 不应该走到这里
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ConfigError);
|
||||
expect((e as ConfigError).message).toContain("ghost");
|
||||
}
|
||||
});
|
||||
|
||||
it("依赖链断开时 planCompleted 仍为 false", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "设计" },
|
||||
{ name: "task", prompt: "任务", depend: ["design"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await createFreshProject();
|
||||
await writeDoc("broken", "task", "# 任务\n");
|
||||
|
||||
const changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
|
||||
const broken = changes[0]!;
|
||||
const taskDoc = broken.documents.find((d) => d.name === "task")!;
|
||||
const designDoc = broken.documents.find((d) => d.name === "design")!;
|
||||
|
||||
expect(taskDoc.completed).toBe(true);
|
||||
expect(taskDoc.dependMet).toBe(false);
|
||||
expect(designDoc.completed).toBe(false);
|
||||
expect(broken.planCompleted).toBe(false);
|
||||
expect(broken.buildUnlocked).toBe(false);
|
||||
});
|
||||
|
||||
it("依赖满足后才允许 build", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "设计" },
|
||||
{ name: "task", prompt: "任务", depend: ["design"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await createFreshProject();
|
||||
const baseRunner = createRunner();
|
||||
|
||||
const buildWithDependCheck: BuildOverride = async (projectDir, changeName, cfg) => {
|
||||
const changes = await scanChanges(projectDir, cfg);
|
||||
const change = changes.find((c) => c.name === changeName);
|
||||
if (!change) throw new Error(`变更 "${changeName}" 不存在`);
|
||||
if (!change.planCompleted) {
|
||||
throw new Error(`变更 "${changeName}" 的 plan 阶段未完成`);
|
||||
}
|
||||
return baseRunner.runBuild(projectDir, changeName, cfg);
|
||||
};
|
||||
|
||||
const scenarioRunner = new ScenarioRunner(baseRunner, { build: buildWithDependCheck });
|
||||
|
||||
await writeDoc("build-dep", "task", "# 任务\n- [ ] do something\n");
|
||||
|
||||
// 依赖未满足时 build 应抛出错误
|
||||
await expect(scenarioRunner.runBuild(getTempDir(), "build-dep", config)).rejects.toThrow(
|
||||
"plan 阶段未完成",
|
||||
);
|
||||
|
||||
await runner.runPlan(getTempDir(), "build-dep", "design", config);
|
||||
|
||||
const changesAfter = await scanChanges(getTempDir(), config);
|
||||
expect(changesAfter[0]!.planCompleted).toBe(true);
|
||||
|
||||
// 依赖满足后 build 应正常执行
|
||||
await expect(scenarioRunner.runBuild(getTempDir(), "build-dep", config)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
126
tests/agent/e2e-error.test.ts
Normal file
126
tests/agent/e2e-error.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { CommandLevelRunner } from "./tier1-command.ts";
|
||||
import { ScenarioRunner } from "./tier2-scenario.ts";
|
||||
import type { PlanOverride } from "./tier2-scenario.ts";
|
||||
import {
|
||||
setupTempDir,
|
||||
cleanupTempDir,
|
||||
getTempDir,
|
||||
createFreshProject,
|
||||
writeDoc,
|
||||
changeFileExists,
|
||||
} from "./fixtures.ts";
|
||||
import { assertNoFile } from "./validators.ts";
|
||||
import { scanChanges } from "../../src/core/scanner.ts";
|
||||
import { loadConfig } from "../../src/core/config.ts";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const brokenPlan: PlanOverride = async (_projectDir, changeName, docName, _cfg) => {
|
||||
const wrongDir = join(getTempDir(), "wrong-dir");
|
||||
await mkdir(wrongDir, { recursive: true });
|
||||
await writeFile(join(wrongDir, `${docName}.md`), "some content\n");
|
||||
|
||||
return {
|
||||
files: [],
|
||||
missed: [`${docName}.md`],
|
||||
};
|
||||
};
|
||||
|
||||
const emptyPlan: PlanOverride = async (_projectDir, _changeName, _docName, _cfg) => {
|
||||
return { files: [] };
|
||||
};
|
||||
|
||||
describe("e2e: 错误场景", () => {
|
||||
beforeEach(async () => {
|
||||
await setupTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempDir();
|
||||
});
|
||||
|
||||
it("agent 文件写错路径", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
const errorRunner = new ScenarioRunner(new CommandLevelRunner(), { plan: brokenPlan });
|
||||
const result = await errorRunner.runPlan(getTempDir(), "wrong-path", "design", config);
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.missed).toEqual(["design.md"]);
|
||||
assertNoFile(getTempDir(), ".rune/changes/wrong-path/design.md");
|
||||
});
|
||||
|
||||
it("agent 跳过依赖文档", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
const skipDepsPlan: PlanOverride = async (_projectDir, changeName, docName, _cfg) => {
|
||||
const standardRunner = new CommandLevelRunner();
|
||||
|
||||
if (docName === "task") {
|
||||
return standardRunner.runPlan(getTempDir(), changeName, "task", config);
|
||||
}
|
||||
|
||||
if (docName === "design") {
|
||||
return standardRunner.runPlan(getTempDir(), changeName, "design", config);
|
||||
}
|
||||
|
||||
return { files: [] };
|
||||
};
|
||||
|
||||
const errorRunner = new ScenarioRunner(new CommandLevelRunner(), { plan: skipDepsPlan });
|
||||
|
||||
await errorRunner.runPlan(getTempDir(), "skip-deps", "task", config);
|
||||
|
||||
expect(changeFileExists("skip-deps", "task.md")).toBe(true);
|
||||
|
||||
let changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
|
||||
const taskDoc = changes[0]!.documents.find((d) => d.name === "task");
|
||||
expect(taskDoc).toBeDefined();
|
||||
expect(taskDoc!.completed).toBe(true);
|
||||
expect(taskDoc!.dependMet).toBe(false);
|
||||
|
||||
const designDoc = changes[0]!.documents.find((d) => d.name === "design");
|
||||
expect(designDoc).toBeDefined();
|
||||
expect(designDoc!.completed).toBe(false);
|
||||
|
||||
await errorRunner.runPlan(getTempDir(), "skip-deps", "design", config);
|
||||
expect(changeFileExists("skip-deps", "design.md")).toBe(true);
|
||||
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
const taskDoc2 = changes[0]!.documents.find((d) => d.name === "task");
|
||||
expect(taskDoc2!.dependMet).toBe(true);
|
||||
});
|
||||
|
||||
it("agent 创建空文件", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
await writeDoc("empty-file", "design", "");
|
||||
|
||||
const errorRunner = new ScenarioRunner(new CommandLevelRunner(), { plan: emptyPlan });
|
||||
const result = await errorRunner.runPlan(getTempDir(), "empty-file", "design", config);
|
||||
|
||||
expect(changeFileExists("empty-file", "design.md")).toBe(true);
|
||||
|
||||
const filePath = join(getTempDir(), ".rune/changes/empty-file/design.md");
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
expect(content).toBe("");
|
||||
expect(result.files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("config.yaml 语法错误", async () => {
|
||||
const runeDir = join(getTempDir(), ".rune");
|
||||
await mkdir(runeDir, { recursive: true });
|
||||
|
||||
const invalidYaml = "stages\n plan documents\n";
|
||||
await writeFile(join(runeDir, "config.yaml"), invalidYaml, "utf-8");
|
||||
|
||||
const loadedConfig = await loadConfig(getTempDir());
|
||||
expect(loadedConfig).toBeDefined();
|
||||
expect(loadedConfig.stages.plan).toBeDefined();
|
||||
expect(loadedConfig.stages.plan!.documents).toBeDefined();
|
||||
});
|
||||
});
|
||||
118
tests/agent/e2e-flow.test.ts
Normal file
118
tests/agent/e2e-flow.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { createRunner } from "./tier1-command.ts";
|
||||
import {
|
||||
setupTempDir,
|
||||
cleanupTempDir,
|
||||
getTempDir,
|
||||
createFreshProject,
|
||||
changeFileExists,
|
||||
} from "./fixtures.ts";
|
||||
import { assertAllTasksDone } from "./validators.ts";
|
||||
import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
|
||||
import { assembleDiscussPrompt } from "../../src/core/assembler.ts";
|
||||
import { validateChangeName } from "../../src/cli.ts";
|
||||
|
||||
describe("e2e: 全流程", () => {
|
||||
let runner: ReturnType<typeof createRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTempDir();
|
||||
runner = createRunner();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempDir();
|
||||
});
|
||||
|
||||
it("完整四阶段流程(discuss → plan → build → archive)", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
const discussPrompt = assembleDiscussPrompt(config);
|
||||
expect(typeof discussPrompt).toBe("string");
|
||||
expect(discussPrompt.length).toBeGreaterThan(0);
|
||||
|
||||
await runner.runPlan(getTempDir(), "full-flow", "design", config);
|
||||
await runner.runPlan(getTempDir(), "full-flow", "task", config);
|
||||
|
||||
let changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0]!.name).toBe("full-flow");
|
||||
expect(changes[0]!.planCompleted).toBe(true);
|
||||
expect(changes[0]!.buildUnlocked).toBe(true);
|
||||
|
||||
const buildResult = await runner.runBuild(getTempDir(), "full-flow", config);
|
||||
expect(buildResult.files.length).toBeGreaterThan(0);
|
||||
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
assertAllTasksDone(changes[0]!);
|
||||
|
||||
await runner.runArchive(getTempDir(), "full-flow", config);
|
||||
|
||||
expect(changeFileExists("full-flow", "task.md")).toBe(false);
|
||||
|
||||
const archives = await scanArchives(getTempDir());
|
||||
expect(archives.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes.find((c) => c.name === "full-flow")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("多变更并行互不干扰", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
await runner.runPlan(getTempDir(), "变更A", "design", config);
|
||||
await runner.runPlan(getTempDir(), "变更A", "task", config);
|
||||
|
||||
await runner.runPlan(getTempDir(), "变更B", "design", config);
|
||||
await runner.runPlan(getTempDir(), "变更B", "task", config);
|
||||
|
||||
let changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(2);
|
||||
|
||||
const changeA = changes.find((c) => c.name === "变更A");
|
||||
const changeB = changes.find((c) => c.name === "变更B");
|
||||
expect(changeA).toBeDefined();
|
||||
expect(changeB).toBeDefined();
|
||||
expect(changeA!.planCompleted).toBe(true);
|
||||
expect(changeB!.planCompleted).toBe(true);
|
||||
|
||||
await runner.runBuild(getTempDir(), "变更A", config);
|
||||
await runner.runBuild(getTempDir(), "变更B", config);
|
||||
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(2);
|
||||
|
||||
assertAllTasksDone(changes.find((c) => c.name === "变更A")!);
|
||||
assertAllTasksDone(changes.find((c) => c.name === "变更B")!);
|
||||
|
||||
expect(changes[0]!.taskProgress?.completed).toBe(changes[0]!.taskProgress?.total);
|
||||
expect(changes[1]!.taskProgress?.completed).toBe(changes[1]!.taskProgress?.total);
|
||||
});
|
||||
|
||||
describe("变更名非法字符拒绝", () => {
|
||||
it("空字符串抛出错误", () => {
|
||||
expect(() => validateChangeName("")).toThrow();
|
||||
});
|
||||
|
||||
it("包含 / 抛出错误", () => {
|
||||
expect(() => validateChangeName("变更/A")).toThrow();
|
||||
});
|
||||
|
||||
it("包含 . 抛出错误", () => {
|
||||
expect(() => validateChangeName("变更.A")).toThrow();
|
||||
});
|
||||
|
||||
it("合法中文名称不抛出错误", () => {
|
||||
expect(() => validateChangeName("变更名")).not.toThrow();
|
||||
});
|
||||
|
||||
it("合法英文名称不抛出错误", () => {
|
||||
expect(() => validateChangeName("my-change")).not.toThrow();
|
||||
});
|
||||
|
||||
it("合法短横线名称不抛出错误", () => {
|
||||
expect(() => validateChangeName("abc-def-xyz")).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import type { RuneConfig } from "../../src/types.ts";
|
||||
|
||||
export interface AgentResult {
|
||||
files: string[];
|
||||
missed?: string[];
|
||||
rawPlan?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentRunner {
|
||||
|
||||
69
tests/agent/tier2-scenario.ts
Normal file
69
tests/agent/tier2-scenario.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
import type { AgentRunner, AgentResult } from "./runner.ts";
|
||||
import { CommandLevelRunner } from "./tier1-command.ts";
|
||||
|
||||
export type PlanOverride = (
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
docName: string,
|
||||
config: RuneConfig,
|
||||
) => Promise<AgentResult>;
|
||||
|
||||
export type BuildOverride = (
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
config: RuneConfig,
|
||||
) => Promise<AgentResult>;
|
||||
|
||||
export type ArchiveOverride = (
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
config: RuneConfig,
|
||||
) => Promise<AgentResult>;
|
||||
|
||||
export interface ScenarioOverrides {
|
||||
plan?: PlanOverride;
|
||||
build?: BuildOverride;
|
||||
archive?: ArchiveOverride;
|
||||
}
|
||||
|
||||
export class ScenarioRunner implements AgentRunner {
|
||||
readonly tier = 2;
|
||||
private defaults: CommandLevelRunner;
|
||||
private overrides: ScenarioOverrides;
|
||||
|
||||
constructor(defaults: CommandLevelRunner, overrides: ScenarioOverrides = {}) {
|
||||
this.defaults = defaults;
|
||||
this.overrides = overrides;
|
||||
}
|
||||
|
||||
async runPlan(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
docName: string,
|
||||
config: RuneConfig,
|
||||
): Promise<AgentResult> {
|
||||
if (this.overrides.plan) {
|
||||
return this.overrides.plan(projectDir, changeName, docName, config);
|
||||
}
|
||||
return this.defaults.runPlan(projectDir, changeName, docName, config);
|
||||
}
|
||||
|
||||
async runBuild(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult> {
|
||||
if (this.overrides.build) {
|
||||
return this.overrides.build(projectDir, changeName, config);
|
||||
}
|
||||
return this.defaults.runBuild(projectDir, changeName, config);
|
||||
}
|
||||
|
||||
async runArchive(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
config: RuneConfig,
|
||||
): Promise<AgentResult> {
|
||||
if (this.overrides.archive) {
|
||||
return this.overrides.archive(projectDir, changeName, config);
|
||||
}
|
||||
return this.defaults.runArchive(projectDir, changeName, config);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user