chore: 移除所有 e2e 测试
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 .",
|
||||
|
||||
@@ -85,7 +85,7 @@ async function stepBumpVersion(): Promise<string> {
|
||||
|
||||
async function runTests(): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -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<LLMPlan> {
|
||||
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<string[]> {
|
||||
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<AgentResult> {
|
||||
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<AgentResult> {
|
||||
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<AgentResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<AgentResult> {
|
||||
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<AgentResult> {
|
||||
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<AgentResult> {
|
||||
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();
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof createRunner>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createRunner>;
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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<typeof createRunner>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export async function cleanupTempDir(): Promise<void> {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function createFreshProject(editors: string[] = ["opencode"]): Promise<RuneConfig> {
|
||||
await runInit(TMP_DIR, editors);
|
||||
return loadConfig(TMP_DIR);
|
||||
}
|
||||
|
||||
export async function createChangeDir(changeName: string): Promise<string> {
|
||||
const dir = getChangeDir(TMP_DIR, changeName);
|
||||
await mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export async function writeDoc(
|
||||
changeName: string,
|
||||
docName: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
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));
|
||||
}
|
||||
@@ -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<AgentResult>;
|
||||
runBuild(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult>;
|
||||
runArchive(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult>;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user