import { mkdtemp, readFile, rm, writeFile } 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, FlowNode } from "../../shared/types"; import type { RunContext } from "../engine/types"; import { getHandler } from "../engine/registry"; import "./utility-extended"; const ids = [ "Array_Reshape2Dim", "Time_GetDatetimeObj", "Time_TimeTypeConversion_V1", "Basic_SetVariable_V1", "Basic_GetProcessInfo", "System_ExecuteCommandV1", "DataService_Diff", "DataService_WorkingDay", "Databook_Clear", "Databook_GetRowAndColCount", "Databook_WriteContent", "Databook_ReadContent", "Databook_DeleteRow", "Databook_DeleteColumn" ] as const; const directories: string[] = []; const flow: FlowDefinition = { schemaVersion: 1, id: "flow-1", name: "本地工具测试", description: "utility", variables: [], nodes: [], edges: [], meta: { createdAt: "2026-10-27", updatedAt: "2026-10-27", appVersion: "0.1.0" } }; const context = { vars: {}, flow, runId: "run-1" } as unknown as RunContext; const component = {} as CatalogComponent; const node = (id: string, outputVar?: string): FlowNode => ({ id: `utility_${id}`, componentId: id, position: { x: 0, y: 0 }, values: {}, options: outputVar ? { outputVar } : {} }); async function run(id: string, args: Record = {}, outputVar?: string): Promise { const handler = getHandler(id); expect(handler, `${id} should be registered`).toBeTruthy(); return handler!(context, node(id, outputVar), args, component); } afterEach(async () => { context.vars = {}; await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); describe("utility extended capabilities", () => { it("registers only real catalog component ids", async () => { const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog; const catalogIds = new Set(catalog.components.map((item) => item.id)); expect(ids.length).toBeGreaterThanOrEqual(10); for (const id of ids) { expect(catalogIds.has(id), `${id} should exist in catalog`).toBe(true); expect(getHandler(id), `${id} should be registered`).toBeTruthy(); } }); it("reshapes arrays and rejects incompatible dimensions", async () => { await expect(run("Array_Reshape2Dim", { arr: "[1,2,3,4,5,6]", x: 2, y: 3 })) .resolves.toEqual([[1, 2, 3], [4, 5, 6]]); await expect(run("Array_Reshape2Dim", { arr: [1, 2, 3], x: 2, y: 2 })) .rejects.toMatchObject({ code: "validation_error" }); }); it("returns datetime details and formats Python-style patterns", async () => { const detail = await run("Time_GetDatetimeObj", { date_input: "2026-10-27 08:09:10" }) as Record; expect(detail).toMatchObject({ year: 2026, month: 10, day: 27, hour: 8, minute: 9, second: 10 }); await expect(run("Time_TimeTypeConversion_V1", { datetime: "2026-10-27 08:09:10", get_type: "%Y/%m/%d %H:%M:%S" })) .resolves.toBe("2026/10/27 08:09:10"); await expect(run("Time_TimeTypeConversion_V1", { datetime: "12:34", get_type: "%H:%M:%S" })) .resolves.toBe("12:34:00"); await expect(run("Time_TimeTypeConversion_V1", { datetime: "2026-02-30" })) .rejects.toMatchObject({ code: "validation_error" }); }); it("converts variable types and exposes current flow information", async () => { await expect(run("Basic_SetVariable_V1", { variableType: "rpa_int", value: "42" }, "answer")).resolves.toBe(42); expect(context.vars.answer).toBe(42); await expect(run("Basic_SetVariable_V1", { variableType: "rpa_list", value: "[1,2]" })).resolves.toEqual([1, 2]); await expect(run("Basic_GetProcessInfo")).resolves.toMatchObject({ id: "flow-1", name: "本地工具测试", runId: "run-1" }); }); it("executes a local command and returns stdout", async () => { const args = process.platform === "win32" ? { command_type: "1", dos_command: "echo utility-ok", get_ret: true } : { command_type: "1", dos_command: "printf utility-ok", get_ret: true }; await expect(run("System_ExecuteCommandV1", args)).resolves.toContain("utility-ok"); }); it("executes BAT files whose path contains spaces and Chinese characters", async () => { if (process.platform !== "win32") return; const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-中文 空格-")); directories.push(directory); const file = path.join(directory, "测试 脚本.bat"); await writeFile(file, "@echo RPA-BAT-PATH-OK\r\n", "ascii"); await expect(run("System_ExecuteCommandV1", { command_type: "2", bat_file: file, get_ret: "1" })).resolves.toBe("RPA-BAT-PATH-OK"); }); it("keeps PowerShell command output in UTF-8", async () => { if (process.platform !== "win32") return; await expect(run("System_ExecuteCommandV1", { command_type: "3", ps_command: "Write-Output '中文输出测试'", get_ret: "1" })).resolves.toBe("中文输出测试"); }); it("compares local text files with structured line changes", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-")); directories.push(directory); const source = path.join(directory, "source.txt"); const target = path.join(directory, "target.txt"); await writeFile(source, "A\nB\nC", "utf8"); await writeFile(target, "A\nD\nC", "utf8"); const result = await run("DataService_Diff", { sourceFilePath: source, targetFilePath: target, showTable: true }) as Record; expect(result).toMatchObject({ identical: false, addedCount: 1, removedCount: 1, sourceLineCount: 3, targetLineCount: 3 }); }); it("evaluates weekends and local holiday/workday overrides", async () => { await expect(run("DataService_WorkingDay", { day: "2026-10-27" })).resolves.toBe(true); await expect(run("DataService_WorkingDay", { day: "2026-10-31" })).resolves.toBe(false); await expect(run("DataService_WorkingDay", { day: "2026-10-31", working_cal: '{"workdays":["2026-10-31"]}' })).resolves.toBe(true); await expect(run("DataService_WorkingDay", { day: "2026-10-27", working_cal: '["2026-10-27"]' })).resolves.toBe(false); }); it("writes, reads, counts, and clears the in-run databook", async () => { await run("Databook_WriteContent", { write_type: "region", column_name: "A", row_num: 1, content: [["name", "score"], ["Alice", 90], ["Bob", 80]] }); await expect(run("Databook_ReadContent", { read_type: "cell", column_name: "B", row_num: 2 })).resolves.toBe(90); await expect(run("Databook_ReadContent", { read_type: "column", column_name: "A" })).resolves.toEqual(["name", "Alice", "Bob"]); await expect(run("Databook_ReadContent", { read_type: "region", column_name: "A", row_num: 2, end_column_name: "B", end_row_num: 3 })) .resolves.toEqual([["Alice", 90], ["Bob", 80]]); await expect(run("Databook_GetRowAndColCount")).resolves.toMatchObject({ rowCount: 3, columnCount: 2 }); await expect(run("Databook_Clear")).resolves.toEqual([]); }); it("deletes selected databook rows and columns", async () => { context.vars.__rpaDatabook = [["A", "B", "C"], [1, 2, 3], [4, 5, 6]]; await run("Databook_DeleteRow", { row_nums: [2] }); await run("Databook_DeleteColumn", { column_names: ["B"] }); expect(context.vars.__rpaDatabook).toEqual([["A", "C"], [4, 6]]); }); });