/** * 仿真测试台端到端(真库)—— **后端检测活动与流程**的那条路: * 起单时把节点定向到指定账号 → 真实任务落到那个人手上 → 全链 Trace 看穿每一步。 * * 这里守三件事: * ① **指派生效**:点名了的节点就派给那个人(不是轮值挑出来的); * ② **任务是真的**:落到被指派人的工作台(他能在自己的任务列表里看到并处理); * ③ **Trace 全链**:工作流 → 节点 → 活动内每一步(工具 / 人卡)逐层挂得上,耗时齐备。 */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; import { migrate } from 'drizzle-orm/node-postgres/migrator'; import { existsSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { TaskId, WorkOrderId } from '@contracts'; import { loadConfig } from '../config.ts'; import { createContainer } from '../container.ts'; import type { Container } from '../container.ts'; import { seedMvp } from '../seed-mvp.ts'; import { advanceOnce } from '../workers/task-advancer.ts'; import { dispatchOnce } from '../workers/dispatcher.ts'; import { createConsumers } from '../workers/consumers.ts'; const IT_DB = 'jcai_it_testbed'; async function provisionDatabase(): Promise<{ url: string; stop(): Promise } | undefined> { const explicit = process.env['JCAI_TEST_DATABASE_URL']; if (explicit !== undefined) return { url: explicit, stop: async () => undefined }; const base = process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai'; try { const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 }); const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]); if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`); await admin.end(); return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined }; } catch { /* 开发实例连不上,试 Testcontainers */ } try { const { PostgreSqlContainer } = await import('@testcontainers/postgresql'); const started = await new PostgreSqlContainer('postgres:16-alpine').start(); return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) }; } catch (error) { process.stderr.write( `⚠️ 测试台 E2E 跳过:连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`, ); return undefined; } } function migrationsFolder(): string { let dir = dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 8; i += 1) { const candidate = join(dir, 'db', 'migrations'); if (existsSync(candidate)) return candidate; const parent = resolve(dir, '..'); if (parent === dir) break; dir = parent; } throw new Error('找不到 db/migrations'); } const database = await provisionDatabase(); let pool: Pool | undefined; let container: Container; let chaihao: string; let ai: string; beforeAll(async () => { if (database === undefined) return; pool = new Pool({ connectionString: database.url, max: 4 }); for (const schema of ['sys', 'ledger', 'mastra', 'queue']) { await pool.query(`drop schema if exists "${schema}" cascade`); await pool.query(`create schema "${schema}"`); } await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); container = await createContainer( loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }), ); await container.org.ensureSeeded(); await seedMvp(container); // 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己) chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id; ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id; }, 180_000); afterAll(async () => { if (database === undefined) return; await container?.close(); await pool?.end(); await database.stop(); }); const consumers = createConsumers(); async function pump(): Promise { for (let i = 0; i < 60; i += 1) { const delivered = await dispatchOnce({ container, consumers }); const advanced = await advanceOnce({ container }); if (delivered === 0 && advanced === 0) return; } throw new Error('泵 60 轮未静止 —— 流转在空转'); } async function currentTask(workflowId: string): Promise { const rows = await pool!.query<{ id: string }>( `select id from sys.task where workflow_id = $1 and terminal is null order by started_at desc limit 1`, [workflowId], ); expect(rows.rowCount, `流 ${workflowId} 应有在途任务`).toBe(1); return rows.rows[0]!.id as TaskId; } /** 以**被指派人**的身份答掉验收单 —— 任务是真的落到他手上,不是测试台代答 */ async function acceptAs(workflowId: string, staffId: string, values: Record = {}): Promise { const taskId = await currentTask(workflowId); const open = await container.humanTool.openWorkOrders(taskId); const order = open.find((o) => o.mode === 'acceptance'); expect(order, '应有待答验收单').toBeDefined(); await container.humanTool.replyWorkOrder( { mode: 'acceptance', workOrderId: order!.id as WorkOrderId, verdict: 'pass', values, selectedPorts: {} }, staffId as never, ); await pump(); } /* * **2026-07-29 起整套跳过 —— 范式统一的直接后果,不是沉默的通过。** * * 这套端到端靠「答活动的验收工单」把流走完;那张工单从前由 flow 引擎的末步发出。 * 活动改为一律案例模型(loop)之后,发不发工单由 Agent 每拍决定 —— * `turn-runner.runTurn` 每一拍都要 `agent.decide`,**环境里没有模型 key 就一步也走不动**。 * * 恢复条件(两件,缺一不可,见 docs/debt.md): * ① 环境有可用模型(JCAI_DEEPSEEK_API_KEY 或等价的实例绑定); * ② 走法改写成 loop 的口径 —— 按 Enabled 集推进,而不是按步序答工单。 * 在此之前**不许把断言改松让它变绿**:绿得不诚实比红着更坏。 */ const SKIP_UNTIL_MODEL = true; // 见上:没有模型,案例活动一步也走不动 describe.skipIf(database === undefined || SKIP_UNTIL_MODEL)('仿真测试台:指派 → 真实任务 → 全链 Trace', () => { let workflowId: string; it('① 起单带节点指派:点名的节点就派给那个人', async () => { const record = await container.process.start({ packageId: 'unified-quote', subject: '测试台试跑', source: { kind: 'registration', ref: 'testbed', by: chaihao }, data: { customerCode: 'C-TB-1', rfqRef: 'RFQ-TB-1', subject: 's', body: 'b' }, // A1 定向给 AI,A4 定向给 Chaihao —— 其余节点照旧轮值 assignments: { A1: ai, A4: chaihao }, }); workflowId = record.id; await pump(); const instances = await container.process.instancesOf(workflowId); const a1 = instances.find((i) => i.nodeId === 'A1'); expect(a1?.assigneeId, 'A1 应派给被点名的 AI').toBe(ai); }); it('② 任务是真的:落在被指派人的任务列表里,他能处理', async () => { const mine = await container.task.listByOwner(ai as never); const hit = mine.find((t) => t.workflowId === workflowId); expect(hit, 'AI 的工作台上应有这一单的任务').toBeDefined(); expect(hit?.ownerId).toBe(ai); // 他本人答掉验收单,流继续往下走 —— 测试台不代答 await acceptAs(workflowId, ai, { needAlternative: 'false', needSourcing: 'false' }); const instances = await container.process.instancesOf(workflowId); expect(instances.find((i) => i.nodeId === 'A1')?.terminal).toBe('released'); }); it('③ 指派对下游节点同样生效:A4 落到点名的人手上', async () => { const instances = await container.process.instancesOf(workflowId); const a4 = instances.find((i) => i.nodeId === 'A4'); expect(a4?.assigneeId, 'A4 应派给被点名的 Chaihao').toBe(chaihao); }); /* * Trace 是这次要的核心:活动内部到底干了什么,时间轴上要看得见 —— * 工作流一条 root,每个节点一个 span,活动内的工具调用/人卡各一个子 span。 */ it('④ 全链 Trace:工作流 → 节点 → 活动内动作,逐层挂得上', async () => { const spans = await container.trace.ofWorkflow(workflowId); expect(spans.length, 'span 不该是空的').toBeGreaterThan(0); const root = spans.find((s) => s.kind === 'workflow'); expect(root, '应有工作流级 root span').toBeDefined(); expect(root?.parentId, 'root 没有父亲').toBeUndefined(); expect(root?.name).toContain('测试台试跑'); const nodes = spans.filter((s) => s.kind === 'node'); expect(nodes.length, '至少 A1 与 A4 两个节点 span').toBeGreaterThanOrEqual(2); expect(nodes.every((n) => n.parentId === root!.id), '节点 span 都挂在 root 下').toBe(true); expect(nodes.find((n) => n.nodeId === 'A1')?.attributes?.['assigneeId'], '节点 span 记着派给了谁').toBe(ai); // 活动内部:A1 的占位骨架会调工具、发人卡 —— 这些子 span 挂在 A1 的节点 span 下 const a1Span = nodes.find((n) => n.nodeId === 'A1'); const inner = spans.filter((s) => s.parentId === a1Span?.id); expect(inner.length, 'A1 内部应有动作 span(工具 / 人卡)').toBeGreaterThan(0); expect(inner.some((s) => s.kind === 'tool' || s.kind === 'human'), '动作 span 有工具或人卡').toBe(true); // 收了尾的 span 有耗时 —— 时间轴才画得出来 const closed = spans.filter((s) => s.endedAt !== undefined); expect(closed.length, '至少有一批 span 已收尾').toBeGreaterThan(0); for (const s of closed) expect(new Date(s.endedAt!).getTime()).toBeGreaterThanOrEqual(new Date(s.startedAt).getTime()); }); it('⑤ 一条流一条 trace:所有 span 同一个 traceId', async () => { const spans = await container.trace.ofWorkflow(workflowId); const traceIds = new Set(spans.map((s) => s.traceId)); expect(traceIds.size, '同一条流的 span 应共用一个 traceId').toBe(1); }); it('⑥ 不指派的节点照旧轮值 —— 指派是加法,不是替换', async () => { const record = await container.process.start({ packageId: 'unified-quote', subject: '不指派的对照单', source: { kind: 'registration', ref: 'testbed', by: chaihao }, data: { customerCode: 'C-TB-2', rfqRef: 'RFQ-TB-2', subject: 's', body: 'b' }, }); await pump(); const instances = await container.process.instancesOf(record.id); const a1 = instances.find((i) => i.nodeId === 'A1'); expect(a1?.assigneeId, '没点名也得有人接单(轮值)').toBeDefined(); }); });