Archived
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
231 lines
11 KiB
TypeScript
231 lines
11 KiB
TypeScript
/**
|
|
* 仿真测试台端到端(真库)—— **后端检测活动与流程**的那条路:
|
|
* 起单时把节点定向到指定账号 → 真实任务落到那个人手上 → 全链 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<void> } | 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<void> {
|
|
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<TaskId> {
|
|
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<string, string> = {}): Promise<void> {
|
|
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();
|
|
});
|
|
});
|