refactor(rpa-core): 测试文件迁至 test/ 目录(对面 workspace 规范:产品源码树禁含测试);修 platform-groups 测试预载顺序耦合
This commit is contained in:
1 parent
62652b4fca
commit
4f1fbc07dc
44 files changed
+196
-187
No files matched your search
@@ -0,0 +1,114 @@
|
||||
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");
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AutomationDriver } from "../../src/driver/types";
|
||||
import { createEngine } from "../../src/engine/engine";
|
||||
import { WindowsDriver } from "../../src/driver/windows/windows-driver";
|
||||
|
||||
describe("driver 平台自动选择(迁移指南五件小事 #2)", () => {
|
||||
it("win32 上惰性创建 WindowsDriver 且实例缓存", () => {
|
||||
const engine = createEngine();
|
||||
expect(engine.driver).toBeInstanceOf(WindowsDriver);
|
||||
expect(engine.driver).toBe(engine.driver);
|
||||
});
|
||||
|
||||
it("非 win32 访问 driver 抛 platform_unsupported", () => {
|
||||
const engine = createEngine();
|
||||
const original = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
||||
try {
|
||||
expect(() => engine.driver).toThrowError(/暂不支持桌面自动化驱动/);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: original, configurable: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEngine 注入(迁移阶段1-A1)", () => {
|
||||
const fakeDriver = (): AutomationDriver =>
|
||||
({
|
||||
mouse: {},
|
||||
keyboard: {},
|
||||
window: {},
|
||||
screen: { size: async () => ({ x: 0, y: 0, width: 800, height: 600 }) },
|
||||
element: {}
|
||||
}) as unknown as AutomationDriver;
|
||||
|
||||
it("createEngine 可注入自定义 driver", () => {
|
||||
const driver = fakeDriver();
|
||||
const engine = createEngine({ driver });
|
||||
expect(engine.driver).toBe(driver);
|
||||
});
|
||||
|
||||
it("注入后不触发平台自动选择(darwin 上也可用)", () => {
|
||||
const driver = fakeDriver();
|
||||
const engine = createEngine({ driver });
|
||||
const original = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
||||
try {
|
||||
expect(engine.driver).toBe(driver);
|
||||
expect(engine.driver.screen.size).toBeTruthy();
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: original, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("两个引擎注入不同 driver 互不影响", () => {
|
||||
const driverA = fakeDriver();
|
||||
const driverB = fakeDriver();
|
||||
const engineA = createEngine({ driver: driverA });
|
||||
const engineB = createEngine({ driver: driverB });
|
||||
expect(engineA.driver).toBe(driverA);
|
||||
expect(engineB.driver).toBe(driverB);
|
||||
expect(engineA.driver).not.toBe(engineB.driver);
|
||||
});
|
||||
|
||||
it("setDriver 可后置替换", () => {
|
||||
const engine = createEngine();
|
||||
const driver = fakeDriver();
|
||||
engine.setDriver(driver);
|
||||
expect(engine.driver).toBe(driver);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowDefinition, FlowNode } from "../../src/types";
|
||||
import { validateFlowGraph, hasBlockingIssues } from "../../src/engine/graph-validator";
|
||||
import { register } from "../../src/engine/registry";
|
||||
import { RuntimeError } from "../../src/engine/types";
|
||||
import { runFlow } from "../../src/engine/runtime";
|
||||
|
||||
type RunFlowOptions = Parameters<typeof runFlow>[1];
|
||||
|
||||
function makeFlow(partial: Partial<FlowDefinition> & { nodes: FlowNode[] }): FlowDefinition {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "flow_test",
|
||||
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, overrides: Partial<FlowNode> = {}): FlowNode {
|
||||
return {
|
||||
id,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
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 = (signal: AbortSignal): RunFlowOptions => ({
|
||||
runId: "run_test",
|
||||
evidenceDir: ".",
|
||||
signal,
|
||||
emit: () => {},
|
||||
log: () => {},
|
||||
componentOf: (n: FlowNode) => fakeComponent(n.componentId),
|
||||
variables: {}
|
||||
});
|
||||
|
||||
const hangUntilAborted = (ctx: { signal: AbortSignal }): Promise<never> =>
|
||||
new Promise((_, reject) => {
|
||||
const onAbort = () => reject(new RuntimeError("cancelled", "运行已取消"));
|
||||
if (ctx.signal.aborted) return onAbort();
|
||||
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
|
||||
register("Test_HangHandler", (ctx) => hangUntilAborted(ctx));
|
||||
|
||||
register("Test_SlowHandler", async (ctx) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new RuntimeError("cancelled", "运行已取消"));
|
||||
};
|
||||
if (ctx.signal.aborted) return onAbort();
|
||||
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
return "done";
|
||||
});
|
||||
|
||||
let nonIdempotentCalls = 0;
|
||||
register("Test_NonIdempotent", async () => {
|
||||
nonIdempotentCalls += 1;
|
||||
throw new Error("boom");
|
||||
}, { idempotent: false });
|
||||
|
||||
let idempotentCalls = 0;
|
||||
register("Test_Idempotent", async () => {
|
||||
idempotentCalls += 1;
|
||||
if (idempotentCalls < 3) throw new Error("transient");
|
||||
return "ok";
|
||||
}, { idempotent: true });
|
||||
|
||||
describe("validateFlowGraph", () => {
|
||||
it("正常图无问题", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual"), node("b", "File_Read")],
|
||||
edges: [{ id: "e1", source: "a", target: "b" }]
|
||||
});
|
||||
expect(hasBlockingIssues(validateFlowGraph(flow))).toBe(false);
|
||||
});
|
||||
|
||||
it("重复节点 ID 记为错误", () => {
|
||||
const flow = makeFlow({ nodes: [node("a", "Trigger.Manual"), node("a", "File_Read")] });
|
||||
const issues = validateFlowGraph(flow);
|
||||
expect(issues.some((issue) => issue.code === "duplicate_node_id" && issue.severity === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("悬空边记为错误", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual")],
|
||||
edges: [{ id: "e1", source: "a", target: "ghost" }]
|
||||
});
|
||||
expect(validateFlowGraph(flow).some((issue) => issue.code === "dangling_edge")).toBe(true);
|
||||
});
|
||||
|
||||
it("自环记为错误", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual")],
|
||||
edges: [{ id: "e1", source: "a", target: "a" }]
|
||||
});
|
||||
expect(validateFlowGraph(flow).some((issue) => issue.code === "self_loop")).toBe(true);
|
||||
});
|
||||
|
||||
it("顶层环记为错误", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual"), node("b", "File_Read"), node("c", "File_Write")],
|
||||
edges: [
|
||||
{ id: "e1", source: "a", target: "b" },
|
||||
{ id: "e2", source: "b", target: "c" },
|
||||
{ id: "e3", source: "c", target: "b" }
|
||||
]
|
||||
});
|
||||
expect(validateFlowGraph(flow).some((issue) => issue.code === "cycle" && issue.severity === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("孤儿子节点与不可达节点", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual"), { ...node("x", "File_Read"), parentId: "ghost" }, node("y", "Http_Get")]
|
||||
});
|
||||
const issues = validateFlowGraph(flow);
|
||||
expect(issues.some((issue) => issue.code === "orphan_child")).toBe(true);
|
||||
expect(issues.some((issue) => issue.code === "unreachable_node")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime 取消/超时/重试门控", () => {
|
||||
it("超时会中止监听尝试信号的 handler", async () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [{ ...node("slow", "Test_SlowHandler"), options: { timeoutMs: 120 } }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error?.code).toBe("timeout");
|
||||
expect(result.error?.reason).toContain("执行超时");
|
||||
});
|
||||
|
||||
it("运行取消时节点状态为 cancelled 且不走 error route", async () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("n1", "Test_HangHandler"), node("err", "File_Write")],
|
||||
edges: [{ id: "e1", source: "n1", target: "err", sourceHandle: "error" }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 50);
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("cancelled");
|
||||
const nodeState = result.nodes.find((item) => item.nodeId === "n1");
|
||||
expect(nodeState?.status).toBe("cancelled");
|
||||
// error 边存在,但取消不应路由到 err 节点
|
||||
expect(result.nodes.some((item) => item.nodeId === "err")).toBe(false);
|
||||
});
|
||||
|
||||
it("非幂等能力忽略 retryCount", async () => {
|
||||
nonIdempotentCalls = 0;
|
||||
const flow = makeFlow({
|
||||
nodes: [{ ...node("r", "Test_NonIdempotent"), options: { retryCount: 3, retryIntervalMs: 1000 } }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(nonIdempotentCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("幂等能力遵循 retryCount 重试后成功", async () => {
|
||||
idempotentCalls = 0;
|
||||
const flow = makeFlow({
|
||||
nodes: [{ ...node("r", "Test_Idempotent"), options: { retryCount: 3, retryIntervalMs: 1000, timeoutMs: 5000 } }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(idempotentCalls).toBe(3);
|
||||
});
|
||||
|
||||
it("流程图校验失败返回 validation_error 且不执行任何节点", async () => {
|
||||
const flow = makeFlow({ nodes: [node("a", "Trigger.Manual"), node("a", "File_Read")] });
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error?.code).toBe("validation_error");
|
||||
expect(result.error?.reason).toContain("流程图校验未通过");
|
||||
expect(result.nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowDefinition } from "../../src/types";
|
||||
import { register } from "../../src/engine/registry";
|
||||
import { runFlow } from "../../src/engine/runtime";
|
||||
|
||||
const components = new Map<string, CatalogComponent>();
|
||||
function component(id: string): CatalogComponent {
|
||||
const value = {
|
||||
id, name: id, kind: "vendor", group: "data", categories: [], categorySignature: "", description: "",
|
||||
messageTemplate: "", baseType: id, functionName: id, functionCalls: [], version: "", tags: [], platformTag: null,
|
||||
icon: "data", risk: "low", fields: [], output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] }, slots: []
|
||||
} as unknown as CatalogComponent;
|
||||
components.set(id, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
for (const id of ["Test.Start", "Test.Fail", "Test.Normal", "Test.Recover"]) component(id);
|
||||
register("Test.Start", () => true);
|
||||
register("Test.Fail", () => { throw new Error("expected failure"); });
|
||||
register("Test.Normal", (ctx) => { ctx.vars.normalRan = true; });
|
||||
register("Test.Recover", (ctx) => { ctx.vars.recovered = true; });
|
||||
|
||||
describe("runtime error route", () => {
|
||||
it("uses the error handle after failure and does not execute the normal branch", async () => {
|
||||
const now = new Date().toISOString();
|
||||
const flow: FlowDefinition = {
|
||||
schemaVersion: 1, id: "error-route", name: "error route", variables: [],
|
||||
nodes: [
|
||||
{ id: "start", componentId: "Test.Start", position: { x: 0, y: 0 }, values: {}, options: {} },
|
||||
{ id: "fail", componentId: "Test.Fail", position: { x: 0, y: 100 }, values: {}, options: { errorRouteEnabled: true } },
|
||||
{ id: "normal", componentId: "Test.Normal", position: { x: 0, y: 200 }, values: {}, options: {} },
|
||||
{ id: "recover", componentId: "Test.Recover", position: { x: 200, y: 100 }, values: {}, options: {} }
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "start", target: "fail" },
|
||||
{ id: "e2", source: "fail", target: "normal" },
|
||||
{ id: "e3", source: "fail", sourceHandle: "error", target: "recover" }
|
||||
],
|
||||
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
|
||||
};
|
||||
const result = await runFlow(flow, {
|
||||
runId: "test", evidenceDir: ".", signal: new AbortController().signal,
|
||||
emit: () => {}, log: () => {}, componentOf: (node) => components.get(node.componentId)
|
||||
});
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables.recovered).toBe(true);
|
||||
expect(result.variables.normalRan).toBeUndefined();
|
||||
expect(result.variables.error).toMatchObject({ reason: "expected failure" });
|
||||
});
|
||||
|
||||
it("uses the normal handle after success and leaves error and disconnected nodes untouched", async () => {
|
||||
const now = new Date().toISOString();
|
||||
const flow: FlowDefinition = {
|
||||
schemaVersion: 1, id: "normal-route", name: "normal route", variables: [],
|
||||
nodes: [
|
||||
{ id: "start", componentId: "Test.Start", position: { x: 0, y: 0 }, values: {}, options: {} },
|
||||
{ id: "normal", componentId: "Test.Normal", position: { x: 0, y: 100 }, values: {}, options: {} },
|
||||
{ id: "recover", componentId: "Test.Recover", position: { x: 200, y: 0 }, values: {}, options: {} },
|
||||
{ id: "orphan", componentId: "Test.Recover", position: { x: 400, y: 400 }, values: {}, options: {} }
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "start", target: "normal" },
|
||||
{ id: "e2", source: "start", sourceHandle: "error", target: "recover" }
|
||||
],
|
||||
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
|
||||
};
|
||||
const result = await runFlow(flow, {
|
||||
runId: "test", evidenceDir: ".", signal: new AbortController().signal,
|
||||
emit: () => {}, log: () => {}, componentOf: (node) => components.get(node.componentId)
|
||||
});
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables.normalRan).toBe(true);
|
||||
expect(result.variables.recovered).toBeUndefined();
|
||||
expect(result.nodes.map((state) => state.nodeId)).toEqual(["start", "normal"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
Catalog,
|
||||
CatalogComponent,
|
||||
FlowDefinition,
|
||||
FlowEdge,
|
||||
FlowNode,
|
||||
RunEvent
|
||||
} from "../../src/types";
|
||||
import "../../src/capabilities";
|
||||
import { register } from "../../src/engine/registry";
|
||||
import { runFlow } from "../../src/engine/runtime";
|
||||
|
||||
const catalog = JSON.parse(
|
||||
readFileSync(path.resolve(process.cwd(), "resources/catalog.json"), "utf8")
|
||||
) as Catalog;
|
||||
const components = new Map(catalog.components.map((component) => [component.id, component]));
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const baseOptions = {
|
||||
timeoutMs: 5_000,
|
||||
retryCount: 0,
|
||||
retryIntervalMs: 1_000,
|
||||
skipOnError: false
|
||||
};
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
componentId: string,
|
||||
values: Record<string, unknown> = {},
|
||||
options: FlowNode["options"] = {},
|
||||
extra: Partial<FlowNode> = {}
|
||||
): FlowNode {
|
||||
return {
|
||||
id,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values,
|
||||
options: { ...baseOptions, ...options },
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function edge(source: string, target: string): FlowEdge {
|
||||
return { id: `${source}->${target}`, source, target };
|
||||
}
|
||||
|
||||
function flow(id: string, nodes: FlowNode[], edges: FlowEdge[] = []): FlowDefinition {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
name: id,
|
||||
variables: [],
|
||||
nodes,
|
||||
edges,
|
||||
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
|
||||
};
|
||||
}
|
||||
|
||||
function testComponent(id: string): CatalogComponent {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
kind: "native",
|
||||
group: "test",
|
||||
categories: ["test"],
|
||||
categorySignature: "test",
|
||||
description: "runtime integration test component",
|
||||
messageTemplate: "",
|
||||
baseType: id,
|
||||
functionName: id,
|
||||
functionCalls: [],
|
||||
version: "1",
|
||||
tags: [],
|
||||
platformTag: null,
|
||||
icon: "",
|
||||
risk: "low",
|
||||
fields: [],
|
||||
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
|
||||
slots: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-runtime-integration-"));
|
||||
tempDirs.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function execute(
|
||||
definition: FlowDefinition,
|
||||
variables: Record<string, unknown> = {},
|
||||
extraComponents: CatalogComponent[] = []
|
||||
) {
|
||||
const directory = await tempDir();
|
||||
const events: Array<Omit<RunEvent, "runId">> = [];
|
||||
const logs: Array<{ message: string; level?: "info" | "warn" | "error"; nodeId?: string }> = [];
|
||||
const componentMap = new Map(components);
|
||||
for (const component of extraComponents) componentMap.set(component.id, component);
|
||||
|
||||
const result = await runFlow(definition, {
|
||||
runId: `run-${definition.id}`,
|
||||
evidenceDir: directory,
|
||||
signal: new AbortController().signal,
|
||||
variables,
|
||||
emit: (event) => events.push(event),
|
||||
log: (message, level, nodeId) => logs.push({ message, level, nodeId }),
|
||||
componentOf: (current) => componentMap.get(current.componentId)
|
||||
});
|
||||
|
||||
return { result, events, logs, directory };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("runFlow integration", () => {
|
||||
it("follows edges in a straight line and passes variables between real capabilities", async () => {
|
||||
const definition = flow(
|
||||
"straight-variable-flow",
|
||||
[
|
||||
node("debug", "Basic_DebugOutput", { msg: "${upper}" }, {}, { position: { x: 0, y: 10 } }),
|
||||
node("upper", "StrUtil_UpperCase", { string: "${source}" }, { outputVar: "upper" }),
|
||||
node("trigger", "Trigger.Manual", {}, {}, { position: { x: 0, y: 300 } }),
|
||||
node("set", "Basic_SetVariable", { value: "hello rpa" }, { outputVar: "source" })
|
||||
],
|
||||
[edge("trigger", "set"), edge("set", "upper"), edge("upper", "debug")]
|
||||
);
|
||||
|
||||
const { result, events, logs } = await execute(definition);
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables).toMatchObject({ source: "hello rpa", upper: "HELLO RPA" });
|
||||
expect(result.nodes.map((state) => state.nodeId)).toEqual(["trigger", "set", "upper", "debug"]);
|
||||
expect(events.filter((event) => event.type === "node.started").map((event) => event.nodeId)).toEqual([
|
||||
"trigger",
|
||||
"set",
|
||||
"upper",
|
||||
"debug"
|
||||
]);
|
||||
expect(logs.some((entry) => entry.nodeId === "debug" && entry.message === "HELLO RPA")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ approved: true, expected: "true", executed: "true-node", absent: "false-node" },
|
||||
{ approved: false, expected: "false", executed: "false-node", absent: "true-node" }
|
||||
])("executes only the IF $expected branch", async ({ approved, expected, executed, absent }) => {
|
||||
const branch = node("branch", "logic_control_if_else", { condition: "${approved}" });
|
||||
const definition = flow("if-branch-flow", [
|
||||
branch,
|
||||
node("true-node", "Basic_SetVariable", { value: "true" }, { outputVar: "branchResult" }, {
|
||||
parentId: branch.id,
|
||||
slot: "statement_if"
|
||||
}),
|
||||
node("false-node", "Basic_SetVariable", { value: "false" }, { outputVar: "branchResult" }, {
|
||||
parentId: branch.id,
|
||||
slot: "statement_else"
|
||||
})
|
||||
]);
|
||||
|
||||
const { result } = await execute(definition, { approved });
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables.branchResult).toBe(expected);
|
||||
expect(result.nodes.find((state) => state.nodeId === executed)?.status).toBe("succeeded");
|
||||
expect(result.nodes.some((state) => state.nodeId === absent)).toBe(false);
|
||||
expect(result.nodes.find((state) => state.nodeId === "branch")?.output).toBe(approved);
|
||||
});
|
||||
|
||||
it("runs a count loop inclusively and exposes the loop index to child nodes", async () => {
|
||||
const loop = node("loop", "logic_control_count_for", {
|
||||
count_index: "iteration",
|
||||
count_start_value: "1",
|
||||
count_end_value: "3",
|
||||
count_step: "1"
|
||||
});
|
||||
const definition = flow("count-loop-flow", [
|
||||
loop,
|
||||
node("accumulate", "Basic_SetVariable", { value: "${total + iteration}" }, { outputVar: "total" }, {
|
||||
parentId: loop.id,
|
||||
slot: "count_for_fn"
|
||||
})
|
||||
]);
|
||||
|
||||
const { result, events } = await execute(definition, { total: 0 });
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables).toMatchObject({ iteration: 3, total: 6 });
|
||||
expect(result.nodes.find((state) => state.nodeId === "loop")?.output).toBe(3);
|
||||
expect(events.filter((event) => event.type === "node.started" && event.nodeId === "accumulate")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("runs an array loop through the last item when end_index is -1", async () => {
|
||||
const loop = node("loop", "logic_control_array_for_v3", {
|
||||
array_name: "items",
|
||||
reverse: false,
|
||||
start_index: 0,
|
||||
end_index: -1
|
||||
});
|
||||
const definition = flow("array-loop-flow", [
|
||||
loop,
|
||||
node("collect", "Basic_DebugOutput", { msg: "${item}" }, {}, {
|
||||
parentId: loop.id,
|
||||
slot: "array_for_fn"
|
||||
})
|
||||
]);
|
||||
|
||||
const { result, events } = await execute(definition, { items: ["glm", "mini", "other", "kimi"] });
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.nodes.find((state) => state.nodeId === "loop")?.output).toBe(4);
|
||||
expect(events.filter((event) => event.type === "node.started" && event.nodeId === "collect").map((event) => event.nodeId)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("retries a transient failure and records the successful attempt", async () => {
|
||||
const component = testComponent("Test.TransientFailure");
|
||||
let calls = 0;
|
||||
register(component.id, () => {
|
||||
calls += 1;
|
||||
if (calls < 3) throw new Error(`temporary failure ${calls}`);
|
||||
return "recovered";
|
||||
});
|
||||
const definition = flow("retry-flow", [
|
||||
node("retry", component.id, {}, { outputVar: "retryResult", retryCount: 2, retryIntervalMs: 1_000 })
|
||||
]);
|
||||
|
||||
const { result, logs } = await execute(definition, {}, [component]);
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(calls).toBe(3);
|
||||
expect(result.variables.retryResult).toBe("recovered");
|
||||
expect(result.nodes[0]).toMatchObject({ status: "succeeded", attempt: 3, output: "recovered" });
|
||||
expect(logs.filter((entry) => entry.level === "warn" && entry.nodeId === "retry")).toHaveLength(2);
|
||||
}, 5_000);
|
||||
|
||||
it("continues after a skipped failure and executes the following node", async () => {
|
||||
const component = testComponent("Test.AlwaysFails");
|
||||
register(component.id, () => {
|
||||
throw new Error("expected failure");
|
||||
});
|
||||
const definition = flow(
|
||||
"skip-on-error-flow",
|
||||
[
|
||||
node("failure", component.id, {}, { skipOnError: true }),
|
||||
node("after", "Basic_SetVariable", { value: "continued" }, { outputVar: "afterFailure" })
|
||||
],
|
||||
[edge("failure", "after")]
|
||||
);
|
||||
|
||||
const { result } = await execute(definition, {}, [component]);
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.nodes.find((state) => state.nodeId === "failure")).toMatchObject({
|
||||
status: "skipped",
|
||||
error: { code: "unknown", reason: "expected failure" }
|
||||
});
|
||||
expect(result.nodes.find((state) => state.nodeId === "after")?.status).toBe("succeeded");
|
||||
expect(result.variables.afterFailure).toBe("continued");
|
||||
});
|
||||
|
||||
it("runs an offline file pipeline with interpolation and persisted output", async () => {
|
||||
const directory = await tempDir();
|
||||
const sourcePath = path.join(directory, "source.txt");
|
||||
const targetPath = path.join(directory, "result.txt");
|
||||
const definition = flow(
|
||||
"file-pipeline-flow",
|
||||
[
|
||||
node("write-source", "File_Write", {
|
||||
path: "${sourcePath}",
|
||||
content: "${inputText}",
|
||||
encode: "utf-8",
|
||||
append: "False"
|
||||
}),
|
||||
node("read-source", "File_Read", { path: "${sourcePath}", encode: "utf-8" }, { outputVar: "fileText" }),
|
||||
node("transform", "StrUtil_UpperCase", { string: "${fileText}" }, { outputVar: "transformed" }),
|
||||
node("write-target", "File_Write", {
|
||||
path: "${targetPath}",
|
||||
content: "${transformed}",
|
||||
encode: "utf-8",
|
||||
append: "False"
|
||||
}),
|
||||
node("read-target", "File_Read", { path: "${targetPath}", encode: "utf-8" }, { outputVar: "finalText" })
|
||||
],
|
||||
[
|
||||
edge("write-source", "read-source"),
|
||||
edge("read-source", "transform"),
|
||||
edge("transform", "write-target"),
|
||||
edge("write-target", "read-target")
|
||||
]
|
||||
);
|
||||
|
||||
const { result } = await execute(definition, {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
inputText: "offline rpa pipeline"
|
||||
});
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables).toMatchObject({
|
||||
fileText: "offline rpa pipeline",
|
||||
transformed: "OFFLINE RPA PIPELINE",
|
||||
finalText: "OFFLINE RPA PIPELINE"
|
||||
});
|
||||
await expect(readFile(targetPath, "utf8")).resolves.toBe("OFFLINE RPA PIPELINE");
|
||||
expect(result.nodes.every((state) => state.status === "succeeded")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user