PRA-only-demo/packages/rpa-core/test/engine/checkpoint-events.test.ts
yu 43ae705629 feat(engine): node.finished 全事件补 attempt 审计字段(无人值守审批对账:对面 actionId=runId:stepId:attempt)
目标:审计侧可对账'第几次尝试成功/失败',为无人值守高危动作的审批绑定提供键
代码逻辑:六处 node.finished 发射点统一带 state.attempt(初值1,重试循环内递增);可选字段向后兼容,无事件对象精确断言受影响
验证:引擎域 24/24 全绿 + 新增契约测试(重试3次终态 attempt=3、跳过=1、全事件 ≥1);typecheck 归零
风险与回滚:低——纯新增可选字段,revert 即回
2026-11-06 17:21:45 +08:00

143 lines
5.7 KiB
TypeScript
Raw Permalink 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 { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowDefinition, FlowNode, RunEvent } from "../../src/types";
import { register } from "../../src/engine/registry";
import { NODE_STATUS_TO_STEP_STATUS, toStepStatus } from "../../src/engine/types";
import { runFlow } from "../../src/engine/runtime";
type EmittedEvent = Omit<RunEvent, "runId">;
type RunFlowOptions = Parameters<typeof runFlow>[1];
function makeFlow(partial: Partial<FlowDefinition> & { nodes: FlowNode[] }): FlowDefinition {
return {
schemaVersion: 1,
id: "flow_checkpoint",
name: "检查点测试流程",
variables: [],
nodes: partial.nodes,
edges: partial.edges || [],
meta: { createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", appVersion: "test" }
};
}
function node(id: string, componentId: string): FlowNode {
return { id, componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
}
const fakeComponent = (id: string): CatalogComponent =>
({
id,
name: id,
version: "1.0.0",
tags: [],
platformTag: null,
icon: "",
risk: "low",
fields: [],
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
slots: [],
functionCalls: []
}) as unknown as CatalogComponent;
const baseOptions = (emit: (event: EmittedEvent) => void): RunFlowOptions => ({
runId: "run_cp",
evidenceDir: ".",
signal: new AbortController().signal,
emit,
log: () => {},
componentOf: (n: FlowNode) => fakeComponent(n.componentId),
variables: {}
});
register("Cp_Idempotent", async () => "ok", { idempotent: true });
register("Cp_NonIdempotent", async () => "ok", { idempotent: false });
register("Cp_DefaultMeta", async () => "ok");
describe("状态映射(NodeState ↔ 对面 StepStatus)", () => {
it("8 个状态全分支映射,唯一非同名是 idle→pending", () => {
expect(toStepStatus("idle")).toBe("pending");
for (const status of ["running", "waiting_user", "succeeded", "failed", "cancelled", "unknown", "skipped"] as const) {
expect(toStepStatus(status)).toBe(status);
}
});
it("映射表为全量双射(无遗漏键)", () => {
expect(Object.keys(NODE_STATUS_TO_STEP_STATUS).sort()).toEqual(
["cancelled", "failed", "idle", "running", "skipped", "succeeded", "unknown", "waiting_user"]
);
});
});
describe("A++ 检查点事件负载", () => {
it("node.finished 携带 checkpointId/resumable/幂等键,非幂等不带键", async () => {
const events: EmittedEvent[] = [];
const flow = makeFlow({
nodes: [node("first", "Cp_Idempotent"), node("second", "Cp_NonIdempotent"), node("third", "Cp_DefaultMeta")],
edges: [
{ id: "e1", source: "first", target: "second" },
{ id: "e2", source: "second", target: "third" }
]
});
const result = await runFlow(flow, baseOptions((event) => events.push(event)));
expect(result.status).toBe("succeeded");
const finished = events.filter((e) => e.type === "node.finished" && e.status === "succeeded");
expect(finished).toHaveLength(3);
const first = finished.find((e) => e.nodeId === "first")!;
expect(first.checkpointId).toBe("first");
expect(first.resumable).toBe(true);
expect(first.idempotencyKey).toBe("run_cp:first");
// 双端透明调度(ADR-R6 补记):本地执行驻地必须随事件上报,供审计区分
expect(first.executor).toBe("local");
// attempt 审计字段(无人值守审批对账:actionId=runId:stepId:attempt)
expect(first.attempt).toBe(1);
const second = finished.find((e) => e.nodeId === "second")!;
expect(second.checkpointId).toBe("second");
expect(second.resumable).toBe(false);
expect(second.idempotencyKey).toBeUndefined();
const third = finished.find((e) => e.nodeId === "third")!;
expect(third.resumable).toBe(true);
expect(third.idempotencyKey).toBe("run_cp:third");
});
it("幂等键由 runId+nodeId 派生,跨重入稳定(重入去重的基石)", async () => {
const flow = makeFlow({ nodes: [node("only", "Cp_Idempotent")] });
const runOnce = async (runId: string): Promise<string | undefined> => {
const list: EmittedEvent[] = [];
await runFlow(flow, { ...baseOptions((event) => list.push(event)), runId });
return list.find((e) => e.type === "node.finished")?.idempotencyKey;
};
expect(await runOnce("run_a")).toBe("run_a:only");
expect(await runOnce("run_a")).toBe("run_a:only");
expect(await runOnce("run_b")).toBe("run_b:only");
});
it("attempt 字段全事件覆盖:失败/跳过/取消/成功都带尝试序号,重试节点终态 attempt 反映实际次数", async () => {
let calls = 0;
register("Cp_Flaky", async () => {
calls += 1;
if (calls < 3) throw new Error("transient");
return "ok";
}, { idempotent: true });
const events: EmittedEvent[] = [];
const flaky = { ...node("flaky", "Cp_Flaky"), options: { retryCount: 2, retryIntervalMs: 1 } };
const flow = makeFlow({
nodes: [flaky, { ...node("skip", "Cp_Idempotent"), enabled: false }],
edges: [{ id: "e1", source: "flaky", target: "skip" }]
});
await runFlow(flow, { ...baseOptions((event) => events.push(event)) });
const byNode = (id: string) => events.filter((e) => e.type === "node.finished" && e.nodeId === id);
const flakyFinished = byNode("flaky");
expect(flakyFinished).toHaveLength(1);
expect(flakyFinished[0].status).toBe("succeeded");
expect(flakyFinished[0].attempt).toBe(3); // 第 3 次尝试成功
expect(byNode("skip")[0].attempt).toBe(1); // 跳过节点也是 1
for (const event of events) {
if (event.type === "node.finished") expect(event.attempt).toBeGreaterThanOrEqual(1);
}
}, 10_000);
});