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 "../engine/registry"; import { RuntimeError } from "../engine/types"; import type { CatalogComponent, FlowNode } from "../types"; import type { RunContext } from "../engine/types"; import "./data"; import "./data-extended"; import "./files"; import "./control"; const ctx = { signal: new AbortController().signal, log: () => {}, vars: {} as Record, 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, context?: RunContext): Promise { 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 }).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); });