import { test } from 'node:test'; import assert from 'node:assert/strict'; import { z } from 'zod'; import { createTools, type ApiFn, type ToolDef, type HttpMethod } from '../src/mcp/index.js'; /** 记录每次 REST 调用的 mock;responder 决定返回值(默认 null)。 */ function mockApi(responder?: (method: HttpMethod, path: string, body?: unknown) => unknown): { api: ApiFn; calls: Array<{ method: HttpMethod; path: string; body?: unknown }>; } { const calls: Array<{ method: HttpMethod; path: string; body?: unknown }> = []; const api: ApiFn = (async (method: HttpMethod, path: string, body?: unknown) => { calls.push({ method, path, body }); return responder ? responder(method, path, body) : null; }) as ApiFn; return { api, calls }; } function tool(tools: ToolDef[], name: string): ToolDef { const t = tools.find((x) => x.name === name); assert.ok(t, `工具 ${name} 应存在`); return t!; } /** 用工具自带的 zod inputSchema 校验参数(同 McpServer 注册时的校验口径) */ function parseArgs(t: ToolDef, args: unknown): Record { return z.object(t.config.inputSchema).parse(args) as Record; } // ---------- 工具齐全性 ---------- test('createTools:读写工具齐全', () => { const names = createTools(mockApi().api).map((t) => t.name); for (const expected of [ // 读 'list_projects', 'list_tasks', 'get_task', 'get_next_executable', 'get_pending_approvals', 'get_agents', 'get_usage', 'list_archived', // 写 'create_project', 'patch_project', 'sync_project', 'create_task', 'decompose_task', 'write_plan', 'write_spec', 'write_operations', 'patch_task', 'update_status', 'decide', 'requeue_task', ]) { assert.ok(names.includes(expected), `应注册工具 ${expected}`); } }); // ---------- zod schema 校验(含 .min() 在 .optional() 之前的边界) ---------- test('patch_task schema:priority 0..2 边界、complexity 枚举、可选字段', () => { const t = tool(createTools(mockApi().api), 'patch_task'); // 合法 assert.deepEqual(parseArgs(t, { taskId: 'tsk_1', priority: 0 }), { taskId: 'tsk_1', priority: 0 }); assert.deepEqual(parseArgs(t, { taskId: 'tsk_1', complexity: 'hard' }), { taskId: 'tsk_1', complexity: 'hard' }); // priority 越界 / 非整数被拒 assert.throws(() => parseArgs(t, { taskId: 'tsk_1', priority: 3 })); assert.throws(() => parseArgs(t, { taskId: 'tsk_1', priority: -1 })); assert.throws(() => parseArgs(t, { taskId: 'tsk_1', priority: 1.5 })); // 非法 complexity assert.throws(() => parseArgs(t, { taskId: 'tsk_1', complexity: 'trivial' })); // 空 taskId 被拒(min(1)) assert.throws(() => parseArgs(t, { taskId: '' })); // title min(1) 在 optional 之前:传则不能空,省略可以 assert.throws(() => parseArgs(t, { taskId: 'tsk_1', title: '' })); assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1' })); }); test('decide schema:action 枚举、reason/merge 可选', () => { const t = tool(createTools(mockApi().api), 'decide'); assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1', action: 'accept' })); assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1', action: 'reject', reason: '请补测试' })); assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1', action: 'accept', merge: false })); assert.throws(() => parseArgs(t, { taskId: 'tsk_1', action: 'approve' })); // reason 给了就不能空串(min(1) 在 optional 之前) assert.throws(() => parseArgs(t, { taskId: 'tsk_1', action: 'reject', reason: '' })); }); test('patch_project schema:可空字段 + 枚举校验', () => { const t = tool(createTools(mockApi().api), 'patch_project'); assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1', verifyCmd: null, model: null, logo: null })); assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1', autonomy: 'auto-easy', concurrency: 2, status: 'paused' })); assert.throws(() => parseArgs(t, { projectId: 'prj_1', autonomy: 'yolo' })); assert.throws(() => parseArgs(t, { projectId: 'prj_1', concurrency: 0 })); assert.throws(() => parseArgs(t, { projectId: 'prj_1', status: 'stopped' })); }); test('list_archived schema:page/size 边界', () => { const t = tool(createTools(mockApi().api), 'list_archived'); assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1' })); assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1', page: 2, size: 50 })); assert.throws(() => parseArgs(t, { projectId: 'prj_1', page: 0 })); assert.throws(() => parseArgs(t, { projectId: 'prj_1', size: 0 })); assert.throws(() => parseArgs(t, { projectId: 'prj_1', size: 101 })); }); // ---------- 对 mock 的调用断言(method / path / body) ---------- test('patch_task → PATCH /api/tasks/:id,body 仅含 patch 字段', async () => { const { api, calls } = mockApi(); const t = tool(createTools(api), 'patch_task'); await t.handler({ taskId: 'tsk_abc', priority: 0, complexity: 'easy' }); assert.equal(calls.length, 1); assert.equal(calls[0].method, 'PATCH'); assert.equal(calls[0].path, '/api/tasks/tsk_abc'); assert.deepEqual(calls[0].body, { priority: 0, complexity: 'easy' }); }); test('patch_project → PATCH /api/projects/:id', async () => { const { api, calls } = mockApi(); const t = tool(createTools(api), 'patch_project'); await t.handler({ projectId: 'prj_x', autonomy: 'auto-approved', verifyCmd: null }); assert.equal(calls[0].method, 'PATCH'); assert.equal(calls[0].path, '/api/projects/prj_x'); assert.deepEqual(calls[0].body, { autonomy: 'auto-approved', verifyCmd: null }); }); test('sync_project → POST /api/projects/:id/sync', async () => { const { api, calls } = mockApi(); const t = tool(createTools(api), 'sync_project'); await t.handler({ projectId: 'prj_y' }); assert.deepEqual({ method: calls[0].method, path: calls[0].path }, { method: 'POST', path: '/api/projects/prj_y/sync' }); }); test('decide → POST /api/tasks/:id/decide,透传 action/merge/reason', async () => { const { api, calls } = mockApi(); const t = tool(createTools(api), 'decide'); await t.handler({ taskId: 'tsk_z', action: 'accept', merge: false }); assert.equal(calls[0].method, 'POST'); assert.equal(calls[0].path, '/api/tasks/tsk_z/decide'); assert.deepEqual(calls[0].body, { action: 'accept', merge: false, reason: undefined }); }); test('update_status → POST /api/tasks/:id/transition', async () => { const { api, calls } = mockApi(); const t = tool(createTools(api), 'update_status'); await t.handler({ taskId: 'tsk_t', to: 'queued' }); assert.equal(calls[0].path, '/api/tasks/tsk_t/transition'); assert.deepEqual(calls[0].body, { to: 'queued' }); }); test('get_agents / get_usage → 对应只读端点', async () => { const { api, calls } = mockApi(); const tools = createTools(api); await tool(tools, 'get_agents').handler({}); await tool(tools, 'get_usage').handler({}); assert.deepEqual(calls.map((c) => `${c.method} ${c.path}`), ['GET /api/agents', 'GET /api/usage']); }); test('get_task:返回 score 与 review 字段(summary/verdict/securityVerdict)', async () => { const taskObj = { id: 'tsk_1', projectId: 'prj_1', parentId: null, depth: 1, title: 'T', complexity: 'easy', status: 'exec_review', priority: 0, deps: [], plan: null, spec: null, operations: null, approvals: [], assignee: null, retryBaseline: 0, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', result: { branch: 'b', worktree: null, diffSummary: null, commits: [], prUrl: null, summary: 'looks good', verdict: 'approve', securitySummary: 'no issues', securityVerdict: 'approve', mergeTaskId: null, }, }; const { api } = mockApi((method, path) => { if (path === '/api/tasks/tsk_1') return taskObj; if (path === '/api/tasks/tsk_1/children') return []; if (path === '/api/projects/prj_1/tasks') return [taskObj]; return null; }); const t = tool(createTools(api), 'get_task'); const res = await t.handler({ taskId: 'tsk_1' }); const payload = JSON.parse(res.content[0].text) as { score: number; review: { summary: string; verdict: string; securityVerdict: string }; }; // P0 自身分=3,无依赖/解锁 → score=3 assert.equal(payload.score, 3); assert.equal(payload.review.summary, 'looks good'); assert.equal(payload.review.verdict, 'approve'); assert.equal(payload.review.securityVerdict, 'approve'); }); test('list_archived:仅终态 + 分页 + 按更新时间倒序', async () => { const mk = (id: string, status: string, updatedAt: string): Record => ({ id, projectId: 'prj_1', parentId: null, depth: 1, title: id, complexity: 'easy', status, priority: 1, deps: [], plan: null, spec: null, operations: null, approvals: [], result: null, assignee: null, retryBaseline: 0, createdAt: updatedAt, updatedAt, }); const tasks = [ mk('t_done1', 'done', '2026-01-01T00:00:00Z'), mk('t_active', 'executing', '2026-01-05T00:00:00Z'), mk('t_cancel', 'cancelled', '2026-01-03T00:00:00Z'), mk('t_done2', 'done', '2026-01-02T00:00:00Z'), ]; const { api } = mockApi((_m, path) => (path === '/api/projects/prj_1/tasks' ? tasks : null)); const t = tool(createTools(api), 'list_archived'); const res = await t.handler({ projectId: 'prj_1', page: 1, size: 2 }); const out = JSON.parse(res.content[0].text) as { total: number; totalPages: number; tasks: Array<{ id: string }> }; assert.equal(out.total, 3); // 仅 done/cancelled assert.equal(out.totalPages, 2); assert.deepEqual(out.tasks.map((x) => x.id), ['t_cancel', 't_done2']); // 倒序:03 > 02 > 01 const res2 = await t.handler({ projectId: 'prj_1', page: 2, size: 2 }); const out2 = JSON.parse(res2.content[0].text) as { tasks: Array<{ id: string }> }; assert.deepEqual(out2.tasks.map((x) => x.id), ['t_done1']); }); test('错误透传:ApiError 转为 isError 工具结果', async () => { const { api } = mockApi(() => { throw new Error('非法状态流转'); }); const t = tool(createTools(api), 'update_status'); const res = await t.handler({ taskId: 'tsk_1', to: 'done' }); assert.equal(res.isError, true); assert.match(res.content[0].text, /非法状态流转/); });