Files
maestro/test/pipeline.test.ts
T
wangjia 027a9cb2d6 fix(tests): 修复三类优化实现后的测试适配
- pipeline.test.ts:PipelineDeps 增加 syncMain 可注入接口(默认用真 git),
  测试 mockDeps 加 syncMain=no-op + runConflict,消除 fake-worktree 上真 git 调用失败
- models.test.ts:reviewer 三档默认 fable-5、project.model 不覆盖复审/conflict、
  MODEL_FALLBACK_CHAIN 三项链;pickFallbackModel 改为取链上下一档(循环)而非首个不等项
- ingest.test.ts:verdict=reject 硬闸测试改为断言未进 exec_review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 14:05:22 +08:00

246 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runPipeline, parseDecompose, type PipelineDeps } from '../src/executor/pipeline.js';
import type { JobSpec, OutboxPayload } from '../src/executor/protocol.js';
import type { Project, Task } from '../src/model/types.js';
import type { RunnerResult } from '../src/executor/runner.js';
import type { ReviewResult } from '../src/executor/reviewer.js';
// ───────────────────────── 固定 fixtures ─────────────────────────
const TASK_ID = 'task-pipe-1';
const RUN_ID = 'run-pipe-1';
const FAKE_DIR = `/tmp/fake-wt/${TASK_ID}`;
const FAKE_BRANCH = `maestro/${TASK_ID}`;
const fakeProject: Project = {
id: 'proj-1', name: 'pipe', repoPath: '/tmp/pipe-repo', defaultBranch: 'main',
verifyCmd: null, autonomy: 'auto-easy', model: null, concurrency: 1, maxRetries: 2,
timeoutMs: 1_800_000, status: 'active', logo: null, sortOrder: 0,
createdAt: '2024-01-01T00:00:00.000Z', lastSyncAt: null,
};
const fakeTask: Task = {
id: TASK_ID, projectId: 'proj-1', parentId: null, depth: 1, title: 'tweak',
complexity: 'easy', status: 'queued', priority: 0, deps: [],
plan: null, spec: null, operations: '在 README.md 追加一行', approvals: [],
result: null, assignee: 'agent', retryBaseline: 0, nextEligibleAt: null,
createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z',
};
const fakeJob: JobSpec = {
runId: RUN_ID, task: fakeTask, project: fakeProject,
worktreeDir: FAKE_DIR, branch: FAKE_BRANCH,
};
const okRun: RunnerResult = {
ok: true, transcriptRef: '/tmp/fake.jsonl', sessionId: 'sess-exec-1',
finalText: '执行自述:改了 README',
};
const okReview: ReviewResult = {
summary: '## 做了什么\nmock 复审通过', verdict: 'approve',
transcriptRef: '/tmp/fake-review.jsonl', sessionId: 'sess-review-1',
};
const okSecurity: ReviewResult = {
summary: '## 安全审计\nmock 审计通过', verdict: 'approve',
transcriptRef: '/tmp/fake-security.jsonl', sessionId: 'sess-security-1',
};
/** 全 mock 依赖(不真起 CC、不动 git):可按用例覆盖。 */
function mockDeps(overrides: Partial<PipelineDeps> = {}): PipelineDeps {
return {
createWorktree: async (_repo, taskId) => ({ dir: `/tmp/fake-wt/${taskId}`, branch: `maestro/${taskId}` }),
worktreeDiff: async () => ({ diffSummary: ' README.md | 1 +', commits: ['abc1234 hello maestro'] }),
verify: async () => ({ ok: true, exitCode: 0, logRef: null }),
runTask: async () => okRun,
reviewCode: async () => okReview,
reviewSecurity: async () => okSecurity,
runPlanner: async () => ({ ok: true, transcriptRef: '/tmp/plan.jsonl', sessionId: 'sess-plan', finalText: '方案正文' }),
runConflict: async () => ({ ok: true, transcriptRef: null, sessionId: null, finalText: null }),
syncMain: async () => null, // no-op:测试环境无真实 git worktree,跳过同步
...overrides,
};
}
/** 收集 emit 出的 payload 序列;返回收集器与 emit 函数。 */
function collector(): { emitted: OutboxPayload[]; emit: (p: OutboxPayload) => void } {
const emitted: OutboxPayload[] = [];
return { emitted, emit: (p) => { emitted.push(p); } };
}
/** 取某类型的(首条)payload。 */
function find<T extends OutboxPayload['type']>(
emitted: OutboxPayload[], type: T,
): Extract<OutboxPayload, { type: T }> | undefined {
return emitted.find((p) => p.type === type) as Extract<OutboxPayload, { type: T }> | undefined;
}
// ───────────────────────── 用例 ─────────────────────────
test('成功路径:emit result(四要素正确)+ 末尾 done,不含 failed', async () => {
const { emitted, emit } = collector();
await runPipeline(fakeJob, mockDeps(), emit);
// 不含 failed
assert.equal(find(emitted, 'failed'), undefined, '成功路径不应 emit failed');
// 末尾是 done
assert.equal(emitted.at(-1)?.type, 'done', '最后一条应为 done');
// result 四要素
const result = find(emitted, 'result');
assert.ok(result, '应 emit result');
assert.equal(result.branch, FAKE_BRANCH);
assert.equal(result.worktree, FAKE_DIR);
assert.equal(result.diffSummary, ' README.md | 1 +');
assert.deepEqual(result.commits, ['abc1234 hello maestro']);
// executor 透传 runner 的 transcriptRef/sessionId
assert.deepEqual(result.executor, { transcriptRef: '/tmp/fake.jsonl', sessionId: 'sess-exec-1' });
// code review reportReviewResult → ReviewReport:丢 sessionId
assert.deepEqual(result.code, {
summary: '## 做了什么\nmock 复审通过', verdict: 'approve', transcriptRef: '/tmp/fake-review.jsonl',
});
// security report
assert.deepEqual(result.security, {
summary: '## 安全审计\nmock 审计通过', verdict: 'approve', transcriptRef: '/tmp/fake-security.jsonl',
});
// done 恰好一条
assert.equal(emitted.filter((p) => p.type === 'done').length, 1);
});
test('双复审收到的执行者自述=runner.finalText', async () => {
let codeReport: string | null = null;
let secReport: string | null = null;
const { emit } = collector();
await runPipeline(fakeJob, mockDeps({
reviewCode: async (_t, _p, _wt, _runId, report) => { codeReport = report; return okReview; },
reviewSecurity: async (_t, _p, _wt, _runId, report) => { secReport = report; return okSecurity; },
}), emit);
assert.equal(codeReport, '执行自述:改了 README');
assert.equal(secReport, '执行自述:改了 README');
});
test('executor 失败:emit failed(携带 error/transcriptRef/sessionId+ done,无 result', async () => {
const { emitted, emit } = collector();
await runPipeline(fakeJob, mockDeps({
runTask: async () => ({ ok: false, transcriptRef: '/tmp/exec-fail.jsonl', sessionId: 'sess-x', error: 'boom 执行炸了' }),
}), emit);
assert.equal(find(emitted, 'result'), undefined, 'executor 失败不应 emit result');
const failed = find(emitted, 'failed');
assert.ok(failed, '应 emit failed');
assert.equal(failed.error, 'boom 执行炸了');
assert.equal(failed.transcriptRef, '/tmp/exec-fail.jsonl');
assert.equal(failed.sessionId, 'sess-x');
assert.equal(emitted.at(-1)?.type, 'done', '末尾应为 done');
});
test('verify 失败:runner ok 但 verify !ok → emit failed(沿用 executor transcript+ done,无 result', async () => {
const { emitted, emit } = collector();
await runPipeline(fakeJob, mockDeps({
verify: async () => ({ ok: false, exitCode: 1, logRef: '/tmp/v.log', error: 'verify 失败(exit 1' }),
}), emit);
assert.equal(find(emitted, 'result'), undefined, 'verify 失败不应 emit result');
const failed = find(emitted, 'failed');
assert.ok(failed, '应 emit failed');
assert.equal(failed.error, 'verify 失败(exit 1');
// transcriptRef/sessionId 沿用 executor 的(okRun
assert.equal(failed.transcriptRef, '/tmp/fake.jsonl');
assert.equal(failed.sessionId, 'sess-exec-1');
assert.equal(emitted.at(-1)?.type, 'done', '末尾应为 done');
});
test('复审抛错不挡:reviewCode 抛错 → result 仍 emitcode.verdict=null 且 summary 含「自动复审失败」', async () => {
const { emitted, emit } = collector();
await runPipeline(fakeJob, mockDeps({
reviewCode: async () => { throw new Error('复审 CC 崩了'); },
}), emit);
const result = find(emitted, 'result');
assert.ok(result, '复审抛错仍应 emit result');
assert.equal(result.code.verdict, null);
assert.equal(result.code.transcriptRef, null);
assert.match(result.code.summary ?? '', /自动复审失败/);
assert.match(result.code.summary ?? '', /复审 CC 崩了/);
// 另一个复审不受影响
assert.equal(result.security.verdict, 'approve');
assert.equal(result.security.summary, '## 安全审计\nmock 审计通过');
assert.equal(find(emitted, 'failed'), undefined, '复审失败不应升级为 failed');
assert.equal(emitted.at(-1)?.type, 'done');
});
test('安全审计抛错不挡:security.verdict=null、summary 含「自动复审失败」,code review 不受影响', async () => {
const { emitted, emit } = collector();
await runPipeline(fakeJob, mockDeps({
reviewSecurity: async () => { throw new Error('审计 CC 崩了'); },
}), emit);
const result = find(emitted, 'result');
assert.ok(result);
assert.equal(result.security.verdict, null);
assert.match(result.security.summary ?? '', /自动复审失败:审计 CC 崩了/);
assert.equal(result.code.verdict, 'approve');
assert.equal(emitted.at(-1)?.type, 'done');
});
// ───────────────────────── planner 分支 ─────────────────────────
const medJob = (over: Partial<JobSpec> = {}): JobSpec => ({ ...fakeJob, runKind: 'planner-spec', task: { ...fakeTask, complexity: 'medium', status: 'speccing' }, ...over });
const hardJob = (over: Partial<JobSpec> = {}): JobSpec => ({ ...fakeJob, runKind: 'planner-decompose', task: { ...fakeTask, complexity: 'hard', status: 'analyzing' }, ...over });
test('planner-specemit spec-result(方案=CC 文本)+ done,不复审/不建 worktree', async () => {
const { emitted, emit } = collector();
let worktreeCalled = false;
await runPipeline(medJob(), mockDeps({
createWorktree: async () => { worktreeCalled = true; return { dir: 'x', branch: 'y' }; },
runPlanner: async () => ({ ok: true, transcriptRef: '/tmp/p.jsonl', sessionId: 's', finalText: '## 改动\n改 a.ts' }),
}), emit);
assert.equal(worktreeCalled, false, 'planner 不建 worktree');
const sr = find(emitted, 'spec-result');
assert.ok(sr, '应 emit spec-result');
assert.equal(sr.spec, '## 改动\n改 a.ts');
assert.equal(find(emitted, 'result'), undefined);
assert.equal(find(emitted, 'failed'), undefined);
assert.equal(emitted.at(-1)?.type, 'done');
});
test('planner-decompose:解析末尾 fenced JSON → emit decompose-result + done', async () => {
const { emitted, emit } = collector();
const txt = '分析:拆成两步。\n```json\n{"plan":"先骨架再实现","subtasks":[{"title":"建骨架","complexity":"easy"},{"title":"实现逻辑","complexity":"medium"}]}\n```';
await runPipeline(hardJob(), mockDeps({ runPlanner: async () => ({ ok: true, transcriptRef: null, sessionId: null, finalText: txt }) }), emit);
const dr = find(emitted, 'decompose-result');
assert.ok(dr, '应 emit decompose-result');
assert.equal(dr.plan, '先骨架再实现');
assert.equal(dr.subtasks.length, 2);
assert.deepEqual(dr.subtasks.map((s) => s.complexity), ['easy', 'medium']);
assert.equal(emitted.at(-1)?.type, 'done');
});
test('planner-decompose:输出无合法 JSON → emit failed', async () => {
const { emitted, emit } = collector();
await runPipeline(hardJob(), mockDeps({ runPlanner: async () => ({ ok: true, transcriptRef: null, sessionId: null, finalText: '我忘了输出 JSON' }) }), emit);
assert.ok(find(emitted, 'failed'), '解析失败应 emit failed');
assert.equal(find(emitted, 'decompose-result'), undefined);
assert.equal(emitted.at(-1)?.type, 'done');
});
test('plannerCC 失败 → emit failed', async () => {
const { emitted, emit } = collector();
await runPipeline(medJob(), mockDeps({ runPlanner: async () => ({ ok: false, transcriptRef: null, sessionId: null, finalText: null, error: 'CC 崩了' }) }), emit);
const f = find(emitted, 'failed');
assert.ok(f);
assert.match(f.error, /CC 崩了/);
assert.equal(emitted.at(-1)?.type, 'done');
});
test('parseDecompose:取最后一个 json 块;非法/缺失 → null', () => {
assert.equal(parseDecompose('no json here'), null);
assert.equal(parseDecompose('```json\n{"subtasks":[]}\n```'), null, '空 subtasks → null');
const ok = parseDecompose('前文\n```json\n{"subtasks":[{"title":"a","complexity":"hard"},{"title":"","complexity":"easy"},{"title":"b","complexity":"x"}]}\n```');
assert.ok(ok);
assert.equal(ok.subtasks.length, 1, '过滤空标题与非法复杂度');
assert.equal(ok.subtasks[0].title, 'a');
});