From bb7d5e740c689f48d1ee5155065a72722e9b6a2b Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Tue, 9 Jun 2026 15:52:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AC=AC=E4=BA=8C=E6=9C=9F=20=E2=80=94?= =?UTF-8?q?=20Tier=202=20=E5=9C=BA=E6=99=AF=E7=BA=A7=20mock=20+=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF/=E6=B5=81=E7=A8=8B/=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/agent/e2e-depend.test.ts | 178 +++++++++++++++++++++++++++++++++ tests/agent/e2e-error.test.ts | 126 +++++++++++++++++++++++ tests/agent/e2e-flow.test.ts | 118 ++++++++++++++++++++++ tests/agent/runner.ts | 2 + tests/agent/tier2-scenario.ts | 69 +++++++++++++ 5 files changed, 493 insertions(+) create mode 100644 tests/agent/e2e-depend.test.ts create mode 100644 tests/agent/e2e-error.test.ts create mode 100644 tests/agent/e2e-flow.test.ts create mode 100644 tests/agent/tier2-scenario.ts diff --git a/tests/agent/e2e-depend.test.ts b/tests/agent/e2e-depend.test.ts new file mode 100644 index 0000000..1ceb20b --- /dev/null +++ b/tests/agent/e2e-depend.test.ts @@ -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; + + 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(); + }); +}); diff --git a/tests/agent/e2e-error.test.ts b/tests/agent/e2e-error.test.ts new file mode 100644 index 0000000..b40eedd --- /dev/null +++ b/tests/agent/e2e-error.test.ts @@ -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(); + }); +}); diff --git a/tests/agent/e2e-flow.test.ts b/tests/agent/e2e-flow.test.ts new file mode 100644 index 0000000..d94831f --- /dev/null +++ b/tests/agent/e2e-flow.test.ts @@ -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; + + 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(); + }); + }); +}); diff --git a/tests/agent/runner.ts b/tests/agent/runner.ts index 178d55a..fd7b345 100644 --- a/tests/agent/runner.ts +++ b/tests/agent/runner.ts @@ -2,6 +2,8 @@ import type { RuneConfig } from "../../src/types.ts"; export interface AgentResult { files: string[]; + missed?: string[]; + rawPlan?: unknown; } export interface AgentRunner { diff --git a/tests/agent/tier2-scenario.ts b/tests/agent/tier2-scenario.ts new file mode 100644 index 0000000..effd1eb --- /dev/null +++ b/tests/agent/tier2-scenario.ts @@ -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; + +export type BuildOverride = ( + projectDir: string, + changeName: string, + config: RuneConfig, +) => Promise; + +export type ArchiveOverride = ( + projectDir: string, + changeName: string, + config: RuneConfig, +) => Promise; + +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 { + 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 { + 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 { + if (this.overrides.archive) { + return this.overrides.archive(projectDir, changeName, config); + } + return this.defaults.runArchive(projectDir, changeName, config); + } +}