diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 46797e8..1f45694 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -37,9 +37,7 @@ tests/ # 测试目录(镜像 src 结构) ## 开发命令 ```bash -bun test # 运行单元/集成测试(排除 agent e2e 测试) -bun run test:e2e # 运行 agent 端到端测试(Tier 1 + 2,< 5s) -bun run test:e2e:llm # 运行 LLM-as-Judge 测试(Tier 3,需设置环境变量) +bun test # 运行单元/集成测试 bun test tests/core/ # 运行指定目录测试 bun run release # 发布新版本(交互式递增版本号、测试门禁、git commit+tag、npm publish) bun src/cli.ts init opencode # 测试 init 命令 @@ -50,16 +48,6 @@ bun src/cli.ts help init # 查看 init 命令帮助 bun src/cli.ts version # 查看版本号 ``` -### Tier 3 LLM-as-Judge 环境变量 - -```bash -export RUNE_E2E_LLM_API_KEY="your-api-key" # 必填 -export RUNE_E2E_LLM_PROVIDER="openai" # 可选,默认 openai -export RUNE_E2E_LLM_MODEL="gpt-4o-mini" # 可选,默认 gpt-4o-mini -export RUNE_E2E_LLM_BASE_URL="https://..." # 可选,自定义 endpoint -bun run test:e2e:llm -``` - ### 代码质量 项目使用 oxlint 进行静态分析,oxfmt 进行代码格式化,提交时通过 husky + lint-staged 自动检查。 @@ -134,22 +122,6 @@ CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version` 在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。覆盖 `src/core/`、`src/cli/`、`src/adapters/`、`tests/integration/`。 -### Agent 端到端测试(`bun run test:e2e`) - -位于 `tests/agent/`,灰度盒测试互补现有白盒测试,三层架构: - -| Tier | 说明 | 触发 | -| -------------- | ---------------------------- | ------------------------------ | -| 1 命令级 mock | 每命令预设行为,CI 快速门禁 | `bun run test:e2e` | -| 2 场景级 mock | 行为重写,覆盖边界和错误恢复 | `bun run test:e2e` | -| 3 LLM-as-Judge | 调用 LLM API 验证提示词质量 | `bun run test:e2e:llm`(手动) | - -运行策略: - -- `bun test`(pre-commit 用):Tier 1 + 2 **不参与**,仅跑单元/集成 -- `bun run test:e2e`:Tier 1 + 2(< 5s) -- `bun run test:e2e:llm`:Tier 3(手动触发,需 `RUNE_E2E_LLM_API_KEY`) - ## 发布流程 `bun run release` 交互式发布新版本到 npm: diff --git a/package.json b/package.json index 9022b99..5dd8fd7 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,7 @@ "module": "src/cli.ts", "scripts": { "prepare": "husky", - "test": "bun test --path-ignore-patterns 'tests/agent/**'", - "test:e2e": "bun test tests/agent/ --path-ignore-patterns 'e2e-llm-judge*'", - "test:e2e:llm": "bun test tests/agent/e2e-llm-judge.test.ts --timeout 120000", + "test": "bun test", "lint": "oxlint", "format": "oxfmt .", "format:check": "oxfmt --check .", diff --git a/scripts/release.ts b/scripts/release.ts index fc7916e..b664bf1 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -85,7 +85,7 @@ async function stepBumpVersion(): Promise { async function runTests(): Promise { console.log("\n运行测试..."); - const proc = Bun.spawn(["bun", "test", "--path-ignore-patterns", "tests/agent/**"], { + const proc = Bun.spawn(["bun", "test"], { stdio: ["inherit", "inherit", "inherit"], }); const exitCode = await proc.exited; diff --git a/tests/agent/agent-llm.ts b/tests/agent/agent-llm.ts deleted file mode 100644 index 71d3b1c..0000000 --- a/tests/agent/agent-llm.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { RuneConfig } from "../../src/types.ts"; -import type { AgentRunner, AgentResult } from "./runner.ts"; -import { - assemblePlanPrompt, - assembleBuildPrompt, - assembleArchivePrompt, -} from "../../src/core/assembler.ts"; - -export interface LLMAction { - type: "write_file" | "check_task" | "done"; - path?: string; - content?: string; - taskIndex?: number; -} - -export interface LLMPlan { - actions: LLMAction[]; -} - -export function isLLMAvailable(): boolean { - return !!process.env.RUNE_E2E_LLM_API_KEY; -} - -export function getLLMEnv() { - return { - provider: process.env.RUNE_E2E_LLM_PROVIDER || "openai", - model: process.env.RUNE_E2E_LLM_MODEL || "gpt-4o-mini", - apiKey: process.env.RUNE_E2E_LLM_API_KEY || "", - baseUrl: process.env.RUNE_E2E_LLM_BASE_URL || "https://api.openai.com/v1", - }; -} - -async function callLLM(prompt: string): Promise { - const { provider, model, apiKey, baseUrl } = getLLMEnv(); - - if (provider === "openai" || provider === "openrouter") { - const response = await fetch(`${baseUrl}/chat/completions`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model, - messages: [ - { - role: "system", - content: `你是一个自动化构建工具,负责根据提示词生成精确的文件操作计划。 - -请严格按以下 JSON 格式输出行动计划(不要包含其他内容): -{ - "actions": [ - { "type": "write_file", "path": "相对路径", "content": "文件内容" }, - { "type": "check_task", "taskIndex": 0 } - ] -} - -可用的 action 类型: -- write_file: 写入文件,path 和 content 必填 -- check_task: 标记任务为已完成,taskIndex 是任务列表中从 0 开始的索引 -- done: 表示所有操作已完成 - -根据提示词要求,生成完整的操作计划。不要跳过任何步骤。`, - }, - { - role: "user", - content: prompt, - }, - ], - temperature: undefined, - }), - }); - - if (!response.ok) { - const body = await response.text(); - throw new Error(`LLM API error ${response.status}: ${body}`); - } - - const data = await response.json(); - const text = data.choices?.[0]?.message?.content || ""; - return parseLLMResponse(text); - } - - if (provider === "anthropic") { - const response = await fetch(`https://api.anthropic.com/v1/messages`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model, - max_tokens: 4096, - system: `你是一个自动化构建工具,负责根据提示词生成精确的文件操作计划。 - -请严格按以下 JSON 格式输出行动计划(不要包含其他内容): -{ - "actions": [ - { "type": "write_file", "path": "相对路径", "content": "文件内容" }, - { "type": "check_task", "taskIndex": 0 } - ] -} - -可用的 action 类型: -- write_file: 写入文件,path 和 content 必填 -- check_task: 标记任务为已完成,taskIndex 是任务列表中从 0 开始的索引 -- done: 表示所有操作已完成 - -根据提示词要求,生成完整的操作计划。不要跳过任何步骤。`, - messages: [{ role: "user", content: prompt }], - temperature: undefined, - }), - }); - - if (!response.ok) { - const body = await response.text(); - throw new Error(`LLM API error ${response.status}: ${body}`); - } - - const data = await response.json(); - const text = data.content?.[0]?.text || ""; - return parseLLMResponse(text); - } - - throw new Error(`不支持的 LLM provider: ${provider}`); -} - -function parseLLMResponse(text: string): LLMPlan { - const jsonMatch = text.match(/\{[\s\S]*\}/); - if (!jsonMatch) { - throw new Error(`LLM 输出中未找到 JSON: ${text.slice(0, 200)}`); - } - - try { - const plan: LLMPlan = JSON.parse(jsonMatch[0]); - if (!plan.actions || !Array.isArray(plan.actions)) { - throw new Error("LLM 输出缺少 actions 数组"); - } - return plan; - } catch (e) { - throw new Error(`LLM 输出 JSON 解析失败: ${text.slice(0, 200)}`, { cause: e }); - } -} - -import { mkdir, writeFile, readFile, rename } from "node:fs/promises"; -import { join } from "node:path"; -import { getChangeDir, getArchiveDir } from "../../src/core/config.ts"; -import { parseTasks } from "../../src/core/task-parser.ts"; - -async function executeActions(projectDir: string, plan: LLMPlan): Promise { - const files: string[] = []; - - for (const action of plan.actions) { - switch (action.type) { - case "write_file": { - if (!action.path || action.content === undefined) { - throw new Error("write_file action 缺少 path 或 content"); - } - const fullPath = join(projectDir, action.path); - await mkdir(join(fullPath, ".."), { recursive: true }); - await writeFile(fullPath, action.content); - files.push(action.path); - break; - } - case "check_task": { - if (action.taskIndex === undefined) { - throw new Error("check_task action 缺少 taskIndex"); - } - break; - } - case "done": - break; - } - } - - return files; -} - -function extractTaskCheckActions(plan: LLMPlan): number[] { - return plan.actions - .filter((a) => a.type === "check_task" && a.taskIndex !== undefined) - .map((a) => a.taskIndex!); -} - -export class LLMJudgeRunner implements AgentRunner { - readonly tier = 3; - - async runPlan( - projectDir: string, - changeName: string, - docName: string, - config: RuneConfig, - ): Promise { - if (!isLLMAvailable()) { - throw new Error("RUNE_E2E_LLM_PROVIDER 和 RUNE_E2E_LLM_API_KEY 未设置"); - } - - const prompt = await assemblePlanPrompt(config, projectDir, changeName, docName); - const plan = await callLLM(prompt); - - return { - files: [], - rawPlan: plan, - }; - } - - async runBuild(projectDir: string, changeName: string, config: RuneConfig): Promise { - if (!isLLMAvailable()) { - throw new Error("RUNE_E2E_LLM_PROVIDER 和 RUNE_E2E_LLM_API_KEY 未设置"); - } - - const changeDir = getChangeDir(projectDir, changeName); - const prompt = await assembleBuildPrompt(config, projectDir, changeName); - const plan = await callLLM(prompt); - - const files = await executeActions(projectDir, plan); - - const taskIndices = extractTaskCheckActions(plan); - if (taskIndices.length > 0) { - const taskPath = join(changeDir, "task.md"); - let taskContent = await readFile(taskPath, "utf-8"); - const tasks = parseTasks(taskContent); - - for (const index of taskIndices) { - if (index < tasks.length) { - const task = tasks[index]; - const oldLine = `- [ ] ${task.text}`; - const newLine = `- [x] ${task.text}`; - taskContent = taskContent.replace(oldLine, newLine); - } - } - - await writeFile(taskPath, taskContent); - files.push("task.md"); - } - - return { - files: [...new Set(files)], - rawPlan: plan, - }; - } - - async runArchive( - projectDir: string, - changeName: string, - config: RuneConfig, - ): Promise { - if (!isLLMAvailable()) { - throw new Error("RUNE_E2E_LLM_PROVIDER 和 RUNE_E2E_LLM_API_KEY 未设置"); - } - - const prompt = await assembleArchivePrompt(config, projectDir, changeName); - const changeDir = getChangeDir(projectDir, changeName); - const plan = await callLLM(prompt); - - const today = new Date().toISOString().slice(0, 10); - const archiveDir = getArchiveDir(projectDir); - await mkdir(archiveDir, { recursive: true }); - const dest = join(archiveDir, `${today}-${changeName}`); - await rename(changeDir, dest); - - return { - files: [], - rawPlan: plan, - }; - } -} diff --git a/tests/agent/agent-mock.ts b/tests/agent/agent-mock.ts deleted file mode 100644 index d76fd4f..0000000 --- a/tests/agent/agent-mock.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { mkdir, writeFile, readFile, rename } from "node:fs/promises"; -import { join } from "node:path"; -import type { RuneConfig, DocumentConfig } from "../../src/types.ts"; -import type { AgentRunner, AgentResult } from "./runner.ts"; -import { getChangeDir, getArchiveDir } from "../../src/core/config.ts"; -import { parseTasks } from "../../src/core/task-parser.ts"; - -export class CommandLevelRunner implements AgentRunner { - readonly tier = 1; - - async runPlan( - projectDir: string, - changeName: string, - docName: string, - config: RuneConfig, - ): Promise { - const changeDir = getChangeDir(projectDir, changeName); - await mkdir(changeDir, { recursive: true }); - - const planStage = config.stages.plan; - if (!planStage) { - throw new Error("plan 阶段未配置"); - } - - const docConfig = planStage.documents.find((d) => d.name === docName); - if (!docConfig) { - throw new Error(`文档 "${docName}" 未在 plan.documents 中配置`); - } - - const content = this.renderDocument(docConfig, changeName); - const filePath = join(changeDir, `${docName}.md`); - await writeFile(filePath, content); - - return { files: [`${docName}.md`] }; - } - - async runBuild( - projectDir: string, - changeName: string, - _config: RuneConfig, - ): Promise { - const changeDir = getChangeDir(projectDir, changeName); - const taskPath = join(changeDir, "task.md"); - - let taskContent: string; - try { - taskContent = await readFile(taskPath, "utf-8"); - } catch { - throw new Error(`变更 "${changeName}" 的 task.md 不存在,请先完成规划`); - } - - const tasks = parseTasks(taskContent); - const pending = tasks.filter((t) => !t.checked); - - if (pending.length === 0) { - return { files: [] }; - } - - const files: string[] = []; - for (const task of pending) { - const oldLine = `- [ ] ${task.text}`; - const newLine = `- [x] ${task.text}`; - taskContent = taskContent.replace(oldLine, newLine); - const implFile = `${task.text - .replace(/[^a-zA-Z\u4e00-\u9fa5]+/g, "-") - .replace(/^-|-$/g, "") - .toLowerCase()}.ts`; - await writeFile(join(changeDir, implFile), `// ${task.text}\n`); - files.push(implFile); - } - - await writeFile(taskPath, taskContent); - files.push("task.md"); - - return { files }; - } - - async runArchive( - projectDir: string, - changeName: string, - _config: RuneConfig, - ): Promise { - const changeDir = getChangeDir(projectDir, changeName); - const taskPath = join(changeDir, "task.md"); - - try { - const taskContent = await readFile(taskPath, "utf-8"); - const tasks = parseTasks(taskContent); - const pending = tasks.filter((t) => !t.checked); - - if (pending.length > 0) { - throw new Error(`变更 "${changeName}" 存在 ${pending.length} 个未完成任务,无法归档`); - } - } catch (e) { - if (e instanceof Error && e.message.includes("未完成任务")) { - throw e; - } - } - - const today = new Date().toISOString().slice(0, 10); - const archiveDir = getArchiveDir(projectDir); - await mkdir(archiveDir, { recursive: true }); - const dest = join(archiveDir, `${today}-${changeName}`); - await rename(changeDir, dest); - - return { files: [] }; - } - - private renderDocument(doc: DocumentConfig, changeName: string): string { - if (doc.template) { - return doc.template.replace(/\{\{change-name\}\}/g, changeName) + "\n"; - } - return `# ${doc.name}\n\n${doc.prompt}\n`; - } -} - -export function createRunner(): AgentRunner { - return new CommandLevelRunner(); -} diff --git a/tests/agent/agent-scenario.ts b/tests/agent/agent-scenario.ts deleted file mode 100644 index c29d54f..0000000 --- a/tests/agent/agent-scenario.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { RuneConfig } from "../../src/types.ts"; -import type { AgentRunner, AgentResult } from "./runner.ts"; -import { CommandLevelRunner } from "./agent-mock.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); - } -} diff --git a/tests/agent/e2e-archive.test.ts b/tests/agent/e2e-archive.test.ts deleted file mode 100644 index 7f30b95..0000000 --- a/tests/agent/e2e-archive.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { createRunner } from "./agent-mock.ts"; -import { - setupTempDir, - cleanupTempDir, - getTempDir, - createFreshProject, - writeDoc, - changeFileExists, -} from "./fixtures.ts"; -import { scanChanges, scanArchives } from "../../src/core/scanner.ts"; -import { getChangeDir } from "../../src/core/config.ts"; - -describe("e2e: archive 阶段", () => { - let runner: ReturnType; - - beforeEach(async () => { - await setupTempDir(); - runner = createRunner(); - }); - - afterEach(async () => { - await cleanupTempDir(); - }); - - it("全部任务完成时变更移至 archive/", async () => { - const config = await createFreshProject(); - await writeDoc("ready", "task", "- [x] 任务\n"); - - await runner.runArchive(getTempDir(), "ready", config); - - expect(changeFileExists("ready", "task.md")).toBe(false); - - const archives = await scanArchives(getTempDir()); - expect(archives.length).toBeGreaterThanOrEqual(1); - - const changes = await scanChanges(getTempDir(), config); - expect(changes.find((c) => c.name === "ready")).toBeUndefined(); - }); - - it("任务未完成时阻止归档", async () => { - const config = await createFreshProject(); - await writeDoc("pending", "task", "- [ ] 未完成\n"); - - await expect(runner.runArchive(getTempDir(), "pending", config)).rejects.toThrow("未完成任务"); - - expect(changeFileExists("pending", "task.md")).toBe(true); - }); - - it("无 task.md 时允许归档", async () => { - const config = await createFreshProject(); - const changeDir = getChangeDir(getTempDir(), "no-task"); - await mkdir(changeDir, { recursive: true }); - await writeFile(join(changeDir, "design.md"), "# 设计\n"); - - await runner.runArchive(getTempDir(), "no-task", config); - - expect(changeFileExists("no-task", "design.md")).toBe(false); - - const archives = await scanArchives(getTempDir()); - expect(archives.length).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/tests/agent/e2e-build.test.ts b/tests/agent/e2e-build.test.ts deleted file mode 100644 index 02c39c3..0000000 --- a/tests/agent/e2e-build.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { createRunner } from "./agent-mock.ts"; -import { - setupTempDir, - cleanupTempDir, - getTempDir, - createFreshProject, - writeDoc, -} from "./fixtures.ts"; -import { scanChanges } from "../../src/core/scanner.ts"; -import { getChangeDir } from "../../src/core/config.ts"; - -describe("e2e: build 阶段", () => { - let runner: ReturnType; - - beforeEach(async () => { - await setupTempDir(); - runner = createRunner(); - }); - - afterEach(async () => { - await cleanupTempDir(); - }); - - it("单任务执行,勾选并产出文件", async () => { - const config = await createFreshProject(); - await writeDoc("auth", "task", "- [ ] 实现登录 API\n"); - - const result = await runner.runBuild(getTempDir(), "auth", config); - - expect(result.files).toContain("task.md"); - expect(result.files.length).toBeGreaterThanOrEqual(2); - - const tasks = await scanChanges(getTempDir(), config); - expect(tasks[0].taskProgress).toEqual({ completed: 1, total: 1 }); - }); - - it("多任务按顺序逐个勾选", async () => { - const config = await createFreshProject(); - await writeDoc("multi-task", "task", `- [ ] 任务A\n- [ ] 任务B\n- [ ] 任务C\n`); - - const result = await runner.runBuild(getTempDir(), "multi-task", config); - - expect(result.files).toHaveLength(4); - expect(result.files).toContain("task.md"); - - const taskContent = readFileSync( - join(getChangeDir(getTempDir(), "multi-task"), "task.md"), - "utf-8", - ); - expect(taskContent).toContain("- [x] 任务A"); - expect(taskContent).toContain("- [x] 任务B"); - expect(taskContent).toContain("- [x] 任务C"); - - const tasks = await scanChanges(getTempDir(), config); - expect(tasks[0].taskProgress).toEqual({ completed: 3, total: 3 }); - }); - - it("空 task 清单时 taskProgress 提示无任务", async () => { - const config = await createFreshProject(); - await writeDoc("empty", "task", "\n"); - - const changes = await scanChanges(getTempDir(), config); - const emptyChange = changes.find((c) => c.name === "empty"); - expect(emptyChange).toBeDefined(); - if (emptyChange) { - expect(emptyChange.taskProgress).toEqual({ completed: 0, total: 0 }); - } - }); - - it("plan 未完成时 build 不可用", async () => { - const config = await createFreshProject(); - - await expect(runner.runBuild(getTempDir(), "no-task", config)).rejects.toThrow( - "task.md 不存在", - ); - }); - - it("任务全部完成后状态为已完成", async () => { - const config = await createFreshProject(); - await writeDoc("done", "task", "- [x] 已完成任务\n"); - - const result = await runner.runBuild(getTempDir(), "done", config); - - expect(result.files).toEqual([]); - - const tasks = await scanChanges(getTempDir(), config); - const doneChange = tasks.find((c) => c.name === "done"); - expect(doneChange).toBeDefined(); - if (doneChange) { - expect(doneChange.taskProgress).toEqual({ completed: 1, total: 1 }); - } - }); -}); diff --git a/tests/agent/e2e-depend.test.ts b/tests/agent/e2e-depend.test.ts deleted file mode 100644 index d3ad33f..0000000 --- a/tests/agent/e2e-depend.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { createRunner } from "./agent-mock.ts"; -import { ScenarioRunner, type BuildOverride } from "./agent-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 deleted file mode 100644 index 5e48a34..0000000 --- a/tests/agent/e2e-error.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { CommandLevelRunner } from "./agent-mock.ts"; -import { ScenarioRunner } from "./agent-scenario.ts"; -import type { PlanOverride } from "./agent-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 deleted file mode 100644 index bae91a0..0000000 --- a/tests/agent/e2e-flow.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { createRunner } from "./agent-mock.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/e2e-llm-judge.test.ts b/tests/agent/e2e-llm-judge.test.ts deleted file mode 100644 index d058f29..0000000 --- a/tests/agent/e2e-llm-judge.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { LLMJudgeRunner, isLLMAvailable } from "./agent-llm.ts"; -import { - setupTempDir, - cleanupTempDir, - getTempDir, - createFreshProject, - writeDoc, -} from "./fixtures.ts"; - -const tier3Available = isLLMAvailable(); - -if (!tier3Available) { - console.log("RUNE_E2E_LLM_ 环境变量未配置,Tier 3 测试已跳过"); -} - -describe("e2e: Tier 3", () => { - const runner = new LLMJudgeRunner(); - const testFn = tier3Available ? it : it.skip; - - beforeEach(async () => { - await setupTempDir(); - }); - - afterEach(async () => { - await cleanupTempDir(); - }); - - testFn( - "plan: 单文档输出有效行动计划", - async () => { - const config = await createFreshProject(); - const result = await runner.runPlan(getTempDir(), "user-auth", "design", config); - - expect(result.rawPlan).toBeDefined(); - expect(result.rawPlan).toHaveProperty("actions"); - const plan = result.rawPlan as { actions: unknown[] }; - expect(plan.actions.length).toBeGreaterThan(0); - }, - 120_000, - ); - - testFn( - "build: 单任务输出有效行动计划", - async () => { - const config = await createFreshProject(); - await writeDoc("auth", "task", "- [ ] 实现登录 API\n"); - - const result = await runner.runBuild(getTempDir(), "auth", config); - - expect(result.rawPlan).toBeDefined(); - expect(result.rawPlan).toHaveProperty("actions"); - }, - 120_000, - ); -}); diff --git a/tests/agent/e2e-plan.test.ts b/tests/agent/e2e-plan.test.ts deleted file mode 100644 index fea6891..0000000 --- a/tests/agent/e2e-plan.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { createRunner } from "./agent-mock.ts"; -import { - setupTempDir, - cleanupTempDir, - getTempDir, - createFreshProject, - changeFileExists, -} from "./fixtures.ts"; -import { assertDocCreated, assertDocContains, assertConfigInvalid } from "./validators.ts"; -import { scanChanges } from "../../src/core/scanner.ts"; -import type { RuneConfig } from "../../src/types.ts"; - -describe("e2e: plan 阶段", () => { - let runner: ReturnType; - - beforeEach(async () => { - await setupTempDir(); - runner = createRunner(); - }); - - afterEach(async () => { - await cleanupTempDir(); - }); - - it("单文档按模板生成在正确路径", async () => { - const config = await createFreshProject(); - const result = await runner.runPlan(getTempDir(), "user-auth", "design", config); - - expect(result.files).toContain("design.md"); - assertDocCreated(getTempDir(), "user-auth", "design"); - assertDocContains(getTempDir(), "user-auth", "design", "user-auth"); - }); - - it("多文档无依赖时均生成", async () => { - const config = await createFreshProject(); - await runner.runPlan(getTempDir(), "my-change", "design", config); - await runner.runPlan(getTempDir(), "my-change", "task", config); - - expect(changeFileExists("my-change", "design.md")).toBe(true); - expect(changeFileExists("my-change", "task.md")).toBe(true); - - const changes = await scanChanges(getTempDir(), config); - expect(changes).toHaveLength(1); - expect(changes[0]!.planCompleted).toBe(true); - expect(changes[0]!.buildUnlocked).toBe(true); - }); - - it("模板变量 {{change-name}} 被替换", async () => { - const config = await createFreshProject(); - await runner.runPlan(getTempDir(), "用户-login", "design", config); - - assertDocContains(getTempDir(), "用户-login", "design", "用户-login 设计文档"); - }); - - it("使用自定义 plan 配置(单文档 spec)", async () => { - const config: RuneConfig = { - stages: { - plan: { - documents: [{ name: "spec", prompt: "生成规格", template: "# {{change-name}} 规格\n" }], - }, - }, - }; - await createFreshProject(["opencode"]); - const result = await runner.runPlan(getTempDir(), "my-feature", "spec", config); - - expect(result.files).toContain("spec.md"); - assertDocContains(getTempDir(), "my-feature", "spec", "my-feature 规格"); - }); - - it("多文档有依赖时 planCompleted 全完成后才为 true", async () => { - const config = await createFreshProject(); - - await runner.runPlan(getTempDir(), "dep-test", "design", config); - - let changes = await scanChanges(getTempDir(), config); - expect(changes[0]!.planCompleted).toBe(false); - - await runner.runPlan(getTempDir(), "dep-test", "task", config); - - changes = await scanChanges(getTempDir(), config); - expect(changes[0]!.planCompleted).toBe(true); - expect(changes[0]!.buildUnlocked).toBe(true); - }); - - it("已有变更再次 plan 另一个文档(重复 plan)", async () => { - const config: RuneConfig = { - stages: { - plan: { - documents: [ - { name: "design", prompt: "生成设计", template: "# 设计 #\n" }, - { name: "task", prompt: "生成任务", template: "# 任务 #\n" }, - ], - }, - }, - }; - - await runner.runPlan(getTempDir(), "multi", "design", config); - assertDocCreated(getTempDir(), "multi", "design"); - - const result = await runner.runPlan(getTempDir(), "multi", "task", config); - expect(result.files).toContain("task.md"); - - const changes = await scanChanges(getTempDir(), config); - expect(changes[0]!.planCompleted).toBe(true); - }); - - it("循环依赖配置被校验拒绝", () => { - const config: RuneConfig = { - stages: { - plan: { - documents: [ - { name: "a", prompt: "a", depend: ["b"] }, - { name: "b", prompt: "b", depend: ["a"] }, - ], - }, - }, - }; - assertConfigInvalid(config); - }); - - it("自依赖配置被校验拒绝", () => { - const config: RuneConfig = { - stages: { - plan: { - documents: [{ name: "design", prompt: "设计", depend: ["design"] }], - }, - }, - }; - assertConfigInvalid(config); - }); -}); diff --git a/tests/agent/fixtures.ts b/tests/agent/fixtures.ts deleted file mode 100644 index 1d4694a..0000000 --- a/tests/agent/fixtures.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { existsSync } from "node:fs"; -import { mkdir, writeFile, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { runInit } from "../../src/commands/init.ts"; -import { loadConfig, getChangeDir } from "../../src/core/config.ts"; -import type { RuneConfig } from "../../src/types.ts"; - -const TMP_DIR = join(import.meta.dir, "__tmp_agent_test__"); - -export function getTempDir(): string { - return TMP_DIR; -} - -export async function setupTempDir(): Promise { - await rm(TMP_DIR, { recursive: true, force: true }); - await mkdir(TMP_DIR, { recursive: true }); -} - -export async function cleanupTempDir(): Promise { - await rm(TMP_DIR, { recursive: true, force: true }); -} - -export async function createFreshProject(editors: string[] = ["opencode"]): Promise { - await runInit(TMP_DIR, editors); - return loadConfig(TMP_DIR); -} - -export async function createChangeDir(changeName: string): Promise { - const dir = getChangeDir(TMP_DIR, changeName); - await mkdir(dir, { recursive: true }); - return dir; -} - -export async function writeDoc( - changeName: string, - docName: string, - content: string, -): Promise { - const dir = getChangeDir(TMP_DIR, changeName); - await mkdir(dir, { recursive: true }); - await writeFile(join(dir, `${docName}.md`), content); -} - -export function changeFileExists(changeName: string, fileName: string): boolean { - return existsSync(join(getChangeDir(TMP_DIR, changeName), fileName)); -} diff --git a/tests/agent/runner.ts b/tests/agent/runner.ts deleted file mode 100644 index fd7b345..0000000 --- a/tests/agent/runner.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { RuneConfig } from "../../src/types.ts"; - -export interface AgentResult { - files: string[]; - missed?: string[]; - rawPlan?: unknown; -} - -export interface AgentRunner { - readonly tier: number; - runPlan( - projectDir: string, - changeName: string, - docName: string, - config: RuneConfig, - ): Promise; - runBuild(projectDir: string, changeName: string, config: RuneConfig): Promise; - runArchive(projectDir: string, changeName: string, config: RuneConfig): Promise; -} diff --git a/tests/agent/validators.ts b/tests/agent/validators.ts deleted file mode 100644 index 94f391c..0000000 --- a/tests/agent/validators.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect } from "bun:test"; -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import type { ChangeStatus } from "../../src/types.ts"; -import { validateConfig } from "../../src/core/config.ts"; -import type { RuneConfig } from "../../src/types.ts"; - -export function assertFileExists(projectDir: string, relativePath: string): void { - expect(existsSync(join(projectDir, relativePath))).toBe(true); -} - -export function assertNoFile(projectDir: string, relativePath: string): void { - expect(existsSync(join(projectDir, relativePath))).toBe(false); -} - -export function assertDirExists(projectDir: string, relativePath: string): void { - expect(existsSync(join(projectDir, relativePath))).toBe(true); -} - -export function assertFileContains( - projectDir: string, - relativePath: string, - expected: string, -): void { - const content = readFileSync(join(projectDir, relativePath), "utf-8"); - expect(content).toContain(expected); -} - -export function assertDocCreated(projectDir: string, changeName: string, docName: string): void { - assertFileExists(projectDir, `.rune/changes/${changeName}/${docName}.md`); - const content = readFileSync( - join(projectDir, `.rune/changes/${changeName}/${docName}.md`), - "utf-8", - ); - expect(content.length).toBeGreaterThan(0); -} - -export function assertDocContains( - projectDir: string, - changeName: string, - docName: string, - expected: string, -): void { - assertFileContains(projectDir, `.rune/changes/${changeName}/${docName}.md`, expected); -} - -export function assertAllTasksDone(change: ChangeStatus): void { - expect(change.taskProgress).not.toBeNull(); - if (change.taskProgress) { - expect(change.taskProgress.completed).toBe(change.taskProgress.total); - } -} - -export function assertTaskProgress(change: ChangeStatus, completed: number, total: number): void { - expect(change.taskProgress).toEqual({ completed, total }); -} - -export function assertConfigValid(config: RuneConfig): void { - expect(() => validateConfig(config)).not.toThrow(); -} - -export function assertConfigInvalid(config: RuneConfig): void { - expect(() => validateConfig(config)).toThrow(); -}