refactor(rpa-core): 测试文件迁至 test/ 目录(对面 workspace 规范:产品源码树禁含测试);修 platform-groups 测试预载顺序耦合

This commit is contained in:
yu committed 2026-11-06 16:14:23 +08:00
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);
});
});