284 lines
19 KiB
TypeScript
284 lines
19 KiB
TypeScript
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, describe, expect, it } from "vitest";
|
|
import { capabilityIds, getHandler } from "../../src/engine/registry";
|
|
import { RuntimeError } from "../../src/engine/types";
|
|
import type { CatalogComponent, FlowNode } from "../../src/types";
|
|
import type { RunContext } from "../../src/engine/types";
|
|
import "../../src/capabilities/data";
|
|
import "../../src/capabilities/data-extended";
|
|
import "../../src/capabilities/files";
|
|
import "../../src/capabilities/control";
|
|
|
|
const ctx = {
|
|
signal: new AbortController().signal,
|
|
log: () => {},
|
|
vars: {} as Record<string, unknown>,
|
|
evidenceDir: "D:/office-test2",
|
|
runId: "smoke-run",
|
|
flow: { id: "smoke-flow", name: "冒烟", description: "", enabled: true, trigger: null, meta: { appVersion: "dev", createdAt: "", updatedAt: "" }, nodes: [] }
|
|
} as unknown as RunContext;
|
|
|
|
const node = { id: "smoke-data", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} } as unknown as FlowNode;
|
|
|
|
async function run(componentId: string, args: Record<string, unknown>, context?: RunContext): Promise<unknown> {
|
|
const handler = getHandler(componentId);
|
|
if (!handler) throw new Error(`组件 ${componentId} 未注册运行时`);
|
|
return handler(context ?? ctx, { ...node, componentId } as unknown as FlowNode, args, {} as CatalogComponent);
|
|
}
|
|
|
|
const sandbox = mkdtempSync(path.join(tmpdir(), "rpa-data-smoke-"));
|
|
afterAll(() => { rmSync(sandbox, { recursive: true, force: true }); });
|
|
|
|
describe("数据处理/文件/编排 冒烟", () => {
|
|
it("字符串族 StrUtil", async () => {
|
|
expect(await run("StrUtil_Len", { string: "你好world" })).toBe(7);
|
|
expect(await run("StrUtil_InStr", { string: "hello", search: "ll" })).toBe(2);
|
|
expect(await run("StrUtil_Replace", { string: "a-b-c", search: "-", replace: "+" })).toBe("a+b+c");
|
|
expect(await run("StrUtil_LowerCase", { string: "AbC" })).toBe("abc");
|
|
expect(await run("StrUtil_UpperCase", { string: "AbC" })).toBe("ABC");
|
|
expect(await run("StrUtil_CaseChange", { string: "abc", case: "首字母大写" })).toBe("Abc");
|
|
expect(await run("StrUtil_CaseChange", { string: "abc", case: "1" })).toBe("ABC");
|
|
expect(await run("StrUtil_CaseChange", { string: "ABC", case: "2" })).toBe("abc");
|
|
expect(await run("StrUtil_Trim", { string: " x " })).toBe("x");
|
|
expect(await run("StrUtil_Left", { string: "hello", length: 2 })).toBe("he");
|
|
expect(await run("StrUtil_Right", { string: "hello", length: 2 })).toBe("lo");
|
|
expect(await run("StrUtil_Mid", { string: "hello", start: 1, length: 3 })).toBe("ell");
|
|
expect(await run("StrUtil_CutOutStrV1", { string: "a[内容]b", start_cut_pos: "a[", end: "]" })).toContain("内容");
|
|
expect(await run("StrUtil_Split", { string: "a,b,c", spliter: "," })).toEqual(["a", "b", "c"]);
|
|
expect(await run("StrUtil_SplitStr", { string: "a; b ;c", diy_symbol: ";" })).toEqual(["a", "b", "c"]);
|
|
expect(await run("StrUtil_AddToStr", { string: "尾", add_str: "头", add_type: "添加到前面", symbol: "-" })).toBe("头-尾");
|
|
expect(await run("StrUtil_ReverseStr", { string: "ab cd", letter_reverse: true })).toBe("dc ba");
|
|
expect(await run("StrUtil_ListToString", { str_list: ["a", "b"], connectors_custom: "|" })).toBe("a|b");
|
|
expect(await run("StrUtil_CompleteStr", { string: "7", sum_length: 3, com_string: "0", com_type: "左边补" })).toBe("007");
|
|
expect(await run("StrUtil_CharCode", { code: 65 })).toBe("A");
|
|
expect(await run("StrUtil_CharAsc", { char: "A" })).toBe(65);
|
|
expect(await run("StrUtil_CreateRandomStr", { string_length: 12 })).toHaveLength(12);
|
|
console.log("[字符串族] 20 项通过");
|
|
});
|
|
|
|
it("列表与集合族 List/Collection", async () => {
|
|
expect(await run("List_CreateList", { data: JSON.stringify([1, 2]) })).toEqual([1, 2]);
|
|
expect(await run("List_ListLength", { arrayRet: [1, 2, 3] })).toBe(3);
|
|
expect(await run("List_InsertList", { arrayRet: ["a", "c"], data: "b", insertType: "指定位置", position: 1 })).toEqual(["a", "b", "c"]);
|
|
expect(await run("List_DeleteList", { arrayRet: ["a", "b", "c"], data: "b" })).toEqual(["a", "c"]);
|
|
expect(await run("List_DeleteList", { arrayRet: ["a", "b", "c"], deleteType: "按位置", position: 0 })).toEqual(["b", "c"]);
|
|
expect(await run("List_GetListItem", { arrayRet: [10, 20], position: 1 })).toBe(20);
|
|
expect(await run("List_SetListItem", { arrayRet: [1, 2], data: 9, position: 0 })).toEqual([9, 2]);
|
|
expect(await run("List_GetIndex", { arrayRet: ["x", "y"], data: "y" })).toBe(1);
|
|
expect(await run("List_ListMerge", { arrayRet1: [1], arrayRet2: [2, 3] })).toEqual([1, 2, 3]);
|
|
expect(await run("List_ListDuplication", { arrayRet: [1, 1, 2] })).toEqual([1, 2]);
|
|
expect(await run("List_ListSorted", { arrayRet: [3, 1, 2] })).toEqual([1, 2, 3]);
|
|
expect(await run("List_ListIntersection", { arrayRet1: [1, 2, 3], arrayRet2: [2, 3, 4] })).toEqual([2, 3]);
|
|
expect(await run("List_ClearList", {})).toEqual([]);
|
|
expect(await run("Collection_Create", {})).toEqual([]);
|
|
const withItem = await run("Collection_Add", { collection: [], value: "a" }) as unknown[];
|
|
expect(withItem).toEqual(["a"]);
|
|
expect(await run("Collection_HasItem", { collection: ["a"], item: "a" })).toBe(true);
|
|
expect(await run("Collection_HasItem", { collection: ["a"], item: "z" })).toBe(false);
|
|
expect(await run("Collection_Count", { collection: ["a", "b"] })).toBe(2);
|
|
expect(await run("Collection_Remove", { collection: ["a", "b"], value: "a" })).toEqual(["b"]);
|
|
console.log("[列表/集合族] 18 项通过");
|
|
});
|
|
|
|
it("字典族 Dict", async () => {
|
|
expect(await run("Dict_Create", { value: JSON.stringify({ a: 1 }) })).toEqual({ a: 1 });
|
|
expect(await run("Dict_SetItem", { dictObj: { a: 1 }, key: "b", value: 2 })).toEqual({ a: 1, b: 2 });
|
|
expect(await run("Dict_GetItem", { dictObj: { a: 1 }, key: "a" })).toBe(1);
|
|
expect(await run("Dict_GetItem", { dictObj: {}, key: "x", default_value: "dft" })).toBe("dft");
|
|
expect(await run("Dict_Keys", { dictObj: { a: 1, b: 2 } })).toEqual(["a", "b"]);
|
|
expect(await run("Dict_Values", { dictObj: { a: 1, b: 2 } })).toEqual([1, 2]);
|
|
expect(await run("Dict_Items", { dictObj: { a: 1 } })).toEqual([["a", 1]]);
|
|
expect(await run("Dict_Delete", { dictObj: { a: 1, b: 2 }, key: "a" })).toEqual({ b: 2 });
|
|
expect(await run("Dict_Update", { dictObj: { a: 1 }, targetDict: { b: 2 } })).toEqual({ a: 1, b: 2 });
|
|
expect(await run("Dict_Clear", {})).toEqual({});
|
|
console.log("[字典族] 10 项通过");
|
|
});
|
|
|
|
it("数学族 Math 与正则族 Regex", async () => {
|
|
expect(await run("Math_Abs", { data: -3.5 })).toBe(3.5);
|
|
expect(await run("Math_Round", { data: 3.14159, digit: 2 })).toBe(3.14);
|
|
expect(await run("Math_Int", { data: 3.2, position: "向上取整" })).toBe(4);
|
|
expect(await run("Math_Sqrt", { data: 16 })).toBe(4);
|
|
expect(await run("Math_Pow", { x: 2, y: 10 })).toBe(1024);
|
|
expect(await run("Math_Sgn", { data: -9 })).toBe(-1);
|
|
expect(await run("Math_Exp", { data: 0 })).toBe(1);
|
|
expect(await run("Math_Hex", { num: 255 })).toBe("FF");
|
|
expect(await run("Math_Oct", { num: 8 })).toBe("10");
|
|
expect(await run("Math_Scale", { sourceNum: "1010", sourceNumScale: 2, targetNumScale: 10 })).toBe("10");
|
|
expect(await run("Math_Sin", { data: 90, unit: "角度" })).toBeCloseTo(1, 5);
|
|
expect(await run("Regex_Test", { text: "abc123", pattern: "\\d+" })).toBe(true);
|
|
expect(await run("Regex_TestNew", { text: "abc", pattern: "\\d" })).toBe(false);
|
|
expect(await run("Regex_FindAll", { text: "a1b22c333", pattern: "\\d+" })).toEqual(["1", "22", "333"]);
|
|
expect(await run("Regex_FindStr", { text: "订单A-100,订单B-200", pattern: "订单[AB]-(\\d+)", index: 1 })).toBe("100");
|
|
expect(await run("Regex_FindAllNew", { text: "2026-11-03", pattern: "\\d+" })).toEqual(["2026", "11", "03"]);
|
|
console.log("[数学/正则族] 16 项通过");
|
|
});
|
|
|
|
it("时间族 Time", async () => {
|
|
expect(String(await run("Time_Now", {}))).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
|
expect(String(await run("Time_Date", {}))).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
expect(String(await run("Time_Time", {}))).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
|
const ts = await run("Time_TimeStamp", {}) as number;
|
|
expect(ts).toBeGreaterThan(1_700_000_000);
|
|
expect(await run("Time_Format", { timestamp: 1767000000, format: "YYYY-MM-DD" })).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
expect(await run("Time_DatetimeToTimestamp", { datetime: "2026-01-01 00:00:00" })).toBe(typeof 1_767_225_600 === "number" ? Math.floor(new Date("2026-01-01 00:00:00").getTime() / 1000) : 0);
|
|
expect(await run("Time_Year", { datetime: "2026-11-03 10:20:30" })).toBe(2026);
|
|
expect(await run("Time_Month", { datetime: "2026-11-03 10:20:30" })).toBe(11);
|
|
expect(await run("Time_Day", { datetime: "2026-11-03 10:20:30" })).toBe(3);
|
|
expect(await run("Time_Hour", { datetime: "2026-11-03 10:20:30" })).toBe(10);
|
|
expect(await run("Time_Minute", { datetime: "2026-11-03 10:20:30" })).toBe(20);
|
|
expect(await run("Time_Second", { datetime: "2026-11-03 10:20:30" })).toBe(30);
|
|
expect(typeof (await run("Time_WeekDay", { datetime: "2026-11-03" }))).toBe("number");
|
|
expect(await run("Time_DateOffset_V1", { date_input: "2026-11-01", offset_unit_num: 2, offset_status: "加", get_type: "YYYY-MM-DD" })).toBe("2026-11-03");
|
|
expect(await run("Time_CalDate", { date_start: "2026-11-01", date_end: "2026-11-03", time_type: "天" })).toBeCloseTo(2, 5);
|
|
expect(await run("Time_TimeTypeConversion", { datetime: "2026-11-03 08:00:00", format: "YYYY/MM/DD" })).toBe("2026/11/03");
|
|
console.log("[时间族] 15 项通过");
|
|
});
|
|
|
|
it("JSON 与 CSV 读写回环", async () => {
|
|
expect(await run("JSON_Stringify", { string: { a: 1 } })).toBe('{"a":1}');
|
|
expect(await run("JSON_Parse", { string: '{"a":1}' })).toEqual({ a: 1 });
|
|
expect(await run("JSON_Parse", { string: "bad" })).toBeNull();
|
|
const csvPath = path.join(sandbox, "t.csv");
|
|
await run("CSV_WriteNew", { path: csvPath, table: [["名", "值"], ["a,b", "含\"引号\""], ["c", "d"]] });
|
|
const back = await run("CSV_ReadNew", { path: csvPath }) as string[][];
|
|
expect(back[0]).toEqual(["名", "值"]);
|
|
expect(back[1]).toEqual(["a,b", '含"引号"']);
|
|
console.log("[JSON/CSV] 通过(含引号逗号转义回环)");
|
|
});
|
|
|
|
it("Ini 族完整生命周期", async () => {
|
|
const iniPath = path.join(sandbox, "config.ini");
|
|
await run("Ini_Write", { file: iniPath, section: "server", key: "host", value: "127.0.0.1" });
|
|
await run("Ini_Write", { file: iniPath, section: "server", key: "port", value: "8080" });
|
|
await run("Ini_Write", { file: iniPath, section: "log", key: "level", value: "info" });
|
|
expect(await run("Ini_Read", { file: iniPath, section: "server", key: "host" })).toBe("127.0.0.1");
|
|
expect(await run("Ini_ListSection", { file: iniPath })).toEqual(["server", "log"]);
|
|
expect(await run("Ini_ListKey", { file: iniPath, section: "server" })).toEqual(["host", "port"]);
|
|
expect(await run("Ini_DeleteKey", { file: iniPath, section: "server", key: "port" })).toBe(true);
|
|
expect(await run("Ini_Read", { file: iniPath, section: "server", key: "port" })).toBe("");
|
|
expect(await run("Ini_DeleteSection", { file: iniPath, section: "log" })).toBe(true);
|
|
expect(await run("Ini_ListSection", { file: iniPath })).toEqual(["server"]);
|
|
console.log("[Ini族] 写读列举删除全链通过");
|
|
});
|
|
|
|
it("文件族全生命周期(沙盒目录)", async () => {
|
|
const dir = path.join(sandbox, "files");
|
|
const created = await run("File_CreateFile", { folder_path: dir, name: "a.txt" }) as string;
|
|
expect(created).toContain("a.txt");
|
|
await run("File_Write", { path: created, content: "第一行" });
|
|
await run("File_Write", { path: created, content: "第二行", append: true });
|
|
expect(await run("File_Read", { path: created })).toBe("第一行第二行");
|
|
expect(await run("File_FileExists", { path: created })).toBe(true);
|
|
expect(await run("File_PathExists", { path: path.join(dir, "nope.txt") })).toBe(false);
|
|
expect(await run("File_PathSize", { path: created })).toBeGreaterThan(0);
|
|
const info = await run("File_GetFileInfo", { file_path: created }) as { name: string; size: number };
|
|
expect(info.name).toBe("a.txt");
|
|
expect(info.size).toBeGreaterThan(0);
|
|
const copy = path.join(dir, "b.txt");
|
|
await run("File_Copy", { pathSrc: created, pathDest: copy });
|
|
expect(await run("File_Read", { path: copy })).toBe("第一行第二行");
|
|
const sub = path.join(dir, "sub");
|
|
await run("File_CreateFolder", { path: sub });
|
|
await run("File_Copy", { pathSrc: copy, pathDest: path.join(sub, "c.txt") });
|
|
const flat = await run("File_GetFileList", { folder_path: dir }) as string[];
|
|
expect(flat.length).toBe(2);
|
|
const all = await run("File_GetFileList", { folder_path: dir, recursive: true }) as string[];
|
|
expect(all.length).toBe(3);
|
|
const kw = await run("File_GetFileList", { folder_path: dir, key_word: "a." }) as string[];
|
|
expect(kw.length).toBe(1);
|
|
const moved = await run("File_Move", { pathSrc: copy, pathDest: path.join(dir, "moved.txt") });
|
|
expect(String(moved)).toContain("moved.txt");
|
|
expect(await run("File_FileExists", { path: copy })).toBe(false);
|
|
const renamed = await run("File_Rename", { pathSrc: moved, name: "renamed.txt" });
|
|
expect(String(renamed)).toContain("renamed.txt");
|
|
const zip = await run("File_CompressFile", { pathSrc: created, pathDest: path.join(sandbox, "pack.zip"), direct_type: "0" });
|
|
expect(String(zip)).toContain("pack.zip");
|
|
const unzipDir = path.join(sandbox, "unpacked");
|
|
await run("File_DecompressFile", { path: path.join(sandbox, "pack.zip"), target: unzipDir, direct_type: "0" });
|
|
expect(await run("File_FileExists", { path: path.join(unzipDir, "a.txt") })).toBe(true);
|
|
expect(await run("File_WaitFile", { file_path: created, time_out: 1 })).toBe(true);
|
|
await expect(run("File_WaitFile", { file_path: path.join(dir, "ghost.txt"), time_out: 1 })).rejects.toThrow(/超时/);
|
|
await run("File_Delete", { file_path: renamed });
|
|
expect(await run("File_FileExists", { path: renamed })).toBe(false);
|
|
console.log("[文件族] 建/写/读/拷/列举/移/改名/压缩解压/等待/删除 全链通过");
|
|
}, 60000);
|
|
|
|
it("数据表族 Bivartable", async () => {
|
|
const table = [["部门", "姓名", "工资"], ["技术", "张三", 100], ["销售", "李四", 80], ["技术", "王五", 90], ["销售", "李四", 80]];
|
|
const bodyOnly = [["技术", "张三", 100], ["销售", "李四", 80], ["技术", "王五", 90], ["销售", "李四", 80]];
|
|
const withHeader = await run("Bivartable_DoCreate", { data: JSON.stringify(bodyOnly), columnList: JSON.stringify(["部门", "姓名", "工资"]) }) as unknown[][];
|
|
expect(withHeader[0]).toEqual(["部门", "姓名", "工资"]);
|
|
expect(withHeader.length).toBe(5);
|
|
expect(await run("Bivartable_GetSumValue", { df: table, sumvaluecols: JSON.stringify(["工资"]) })).toBe(350);
|
|
expect(await run("Bivartable_GetMaxValue", { df: table, maxvaluecols: JSON.stringify(["工资"]) })).toBe(100);
|
|
expect(await run("Bivartable_GetMinValue", { df: table, minvaluecols: JSON.stringify(["工资"]) })).toBe(80);
|
|
expect(await run("Bivartable_GetAvgValue", { df: table, avgvaluecols: JSON.stringify(["工资"]) })).toBe(87.5);
|
|
const grouped = await run("Bivartable_GetSumValue", { df: table, sumvaluecols: JSON.stringify(["工资"]), groupby: JSON.stringify(["部门"]) }) as unknown[][];
|
|
expect(grouped[0]).toEqual(["部门", "工资"]);
|
|
expect(grouped).toContainEqual(["技术", 190]);
|
|
expect(grouped).toContainEqual(["销售", 160]);
|
|
const filtered = await run("Bivartable_DoFilter", { df: table, col_name: "部门", choice: "1", criteria: "技术" }) as unknown[][];
|
|
expect(filtered.length).toBe(3);
|
|
const expr = await run("Bivartable_DoFilter", { df: table, rule: '["工资"] > 85' }) as unknown[][];
|
|
expect(expr.length).toBe(3);
|
|
const dedup = await run("Bivartable_DeleteDuplicateRow", { df: table }) as unknown[][];
|
|
expect(dedup.length).toBe(4);
|
|
console.log("[数据表族] 创建/聚合(含分组)/过滤(简单+表达式)/去重 通过");
|
|
});
|
|
|
|
it("编排控制与日志", async () => {
|
|
const varsCtx = { ...ctx, vars: {} } as unknown as RunContext;
|
|
expect(await run("logic_control_var", { var_name: "k1", var_value: "v1" }, varsCtx)).toBe("v1");
|
|
expect((varsCtx as unknown as { vars: Record<string, unknown> }).vars.k1).toBe("v1");
|
|
await expect(run("logic_control_var", {})).rejects.toThrow(/变量名称/);
|
|
await expect(run("logic_control_break_for", {})).rejects.toBeTruthy();
|
|
await expect(run("logic_control_next_for", {})).rejects.toBeTruthy();
|
|
await expect(run("logic_control_stop", {})).rejects.toBeTruthy();
|
|
await expect(run("logic_control_stop_return", {})).rejects.toBeTruthy();
|
|
expect(await run("Trigger.Manual", {})).toBeNull();
|
|
expect(await run("Trigger.Schedule", {})).toBeNull();
|
|
expect(await run("Trigger.FileArrival", {})).toBeNull();
|
|
const logs: Array<{ m: string; lv: string }> = [];
|
|
const logCtx = { ...ctx, log: (m: string, lv: string) => logs.push({ m, lv }) } as unknown as RunContext;
|
|
expect(await run("Log_Info", { msg: "提示信息" }, logCtx)).toBe("提示信息");
|
|
expect(await run("Log_Error", { msg: "错误信息" }, logCtx)).toBe("错误信息");
|
|
expect(logs.some((l) => l.lv === "error")).toBe(true);
|
|
await expect(run("ExceptionUtil_Throws", { msg: "自定义异常" })).rejects.toThrow(/自定义异常/);
|
|
console.log("[编排/日志] 变量/循环控制信号/触发器/日志/自定义异常 通过");
|
|
});
|
|
|
|
it("全量健康检查: 已注册组件空参调用不产生非结构化崩溃", async () => {
|
|
const ids: string[] = capabilityIds();
|
|
const skip = new Set([
|
|
"FTP_Connect", "FTP_Upload", "FTP_Download", "FTP_Delete", "FTP_Rename", "FTP_Cwd", "FTP_GetFileList", "FTP_CreateDir", "FTP_Exist", "FTP_Disconnect",
|
|
"Database_DBConnect", "Database_SQLExecute", "Database_SingleSQLQuery", "Database_AllSQLQuery_V1", "Database_BatchSQLExecute", "Database_BatchInsertExecute", "Database_CloseDBConnect",
|
|
"File_OpenFile", "File_StartBusiness", "File_UploadBusinessResults", "File_UploadBusinessResults_v1", "File_Collectupload",
|
|
"File_Delete", "File_DeleteFile", "File_EmptyFolder", "File_WaitFile",
|
|
"logic_control_break_for", "logic_control_next_for", "logic_control_stop", "logic_control_stop_return",
|
|
"child_modules_v1", "concurrent_child_process_v1", "Element_exception_monitor_v2", "logic_control_if_ai_vision"
|
|
]);
|
|
const crashes: Array<{ id: string; error: string }> = [];
|
|
let checked = 0;
|
|
for (const id of ids) {
|
|
const handler = getHandler(id);
|
|
if (!handler || skip.has(id)) continue;
|
|
checked += 1;
|
|
try {
|
|
await handler(ctx, { ...node, componentId: id } as unknown as FlowNode, {}, {} as CatalogComponent);
|
|
} catch (error) {
|
|
if (!(error instanceof RuntimeError)) {
|
|
crashes.push({ id, error: `${(error as Error).name}: ${(error as Error).message}` });
|
|
}
|
|
}
|
|
}
|
|
console.log(`[全量健康检查] 空参调用 ${checked} 个已注册组件, 结构化报错全部正常, 非结构化崩溃 ${crashes.length} 个`);
|
|
if (crashes.length) console.log(crashes.map((c) => `${c.id}: ${c.error}`).join("\n"));
|
|
expect(crashes).toEqual([]);
|
|
}, 120000);
|
|
});
|
|
|
|
|