feat: 完善RPA编排与本地能力接入

This commit is contained in:
yu committed 2026-10-27 11:10:38 +08:00
1 parent 6a2cc24898
commit c03380168d
32 files changed
+2763 -185

No files matched your search

+27
View File
@@ -283,6 +283,33 @@ export class BrowserSession implements BrowserLike {
return this.evaluate(script);
}
async download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
const targetFolder = path.resolve(folder);
await fs.mkdir(targetFolder, { recursive: true });
return new Promise<string>((resolve, reject) => {
let settled = false;
const browserSession = this.win.webContents.session;
const onDownload = (_event: Electron.Event, item: Electron.DownloadItem) => {
const target = path.join(targetFolder, filename || item.getFilename());
item.setSavePath(target);
item.once("done", (_doneEvent, state) => {
if (state === "completed") finish(undefined, target);
else finish(new RuntimeError("browser_error", `下载未完成:${state}`));
});
};
const finish = (error?: Error, filePath?: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
browserSession.removeListener("will-download", onDownload);
error ? reject(error) : resolve(filePath || "");
};
const timer = setTimeout(() => finish(new RuntimeError("timeout", `下载超时(${timeoutMs}ms)`)), timeoutMs);
browserSession.once("will-download", onDownload);
void this.click(selector).catch((error) => finish(error instanceof Error ? error : new Error(String(error))));
});
}
async screenshot(filePath: string): Promise<string> {
const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" });
const target = path.resolve(filePath);
+14 -1
View File
@@ -99,13 +99,26 @@ register(["Element_SelectOperation_v1", "Element_SelectOperation"], async (ctx,
return result;
});
register(["WebBrowser_RunJs", "WebBrowser_RunJsInIeByTitle"], async (ctx, node, args) => {
register([
"WebBrowser_RunJs", "WebBrowser_RunJsInIeByTitle", "WebBrowser_RunJsInIeBySelector",
"WebBrowser_RunJsInIeBySelector_v1", "WebBrowser_RunJsInIeBySelector_v2", "WebBrowser_RunJsInIeBySelector_v3",
"WebBrowser_RunJsInIeBySelectorGround"
], async (ctx, node, args) => {
const browser = await session(ctx);
const script = String(args.jsCode ?? args.script ?? args.code ?? "");
if (!script) throw new RuntimeError("validation_error", "JS 代码为空", { nodeId: node.id, componentId: node.componentId });
return browser.runJs(script);
});
register(["WebBrowser_SaveAs", "WebBrowser_SaveAs_V1"], async (ctx, node, args) => {
const browser = await session(ctx);
const folder = String(args.path ?? "");
if (!folder) throw new RuntimeError("validation_error", "下载保存目录为空", { nodeId: node.id, componentId: node.componentId });
const file = await browser.download(selectorOf(args), folder, String(args.filename ?? ""), Number(args.timeout ?? 10000));
ctx.log(`文件已下载 ${file}`, "info", node.id);
return file;
});
register("WebBrowser_Screenshot", async (ctx, node, args) => {
const browser = await session(ctx);
const target = String(args.file_path ?? args.image_path ?? `${ctx.evidenceDir}/${node.id}.png`);
+162
View File
@@ -0,0 +1,162 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import net from "node:net";
import { describe, expect, it, afterEach } from "vitest";
import { PDFDocument } from "pdf-lib";
import { PNG } from "pngjs";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import "./desktop";
import "./office";
import "./pdf";
import "./message";
import "./word";
import { findImage } from "./vision";
const tempDirs: string[] = [];
const node = (componentId: string): FlowNode => ({
id: `smoke_${componentId}`,
componentId,
position: { x: 0, y: 0 },
values: {},
options: {}
});
const component = {} as CatalogComponent;
const context = {} as RunContext;
async function tempDir(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-capability-smoke-"));
tempDirs.push(directory);
return directory;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("capability registration smoke", () => {
it("creates, writes, reads, and saves an Excel workbook", async () => {
const directory = await tempDir();
const create = getHandler("Excel_Cs_NewV5");
const write = getHandler("Excel_Cs_WriteCell");
const read = getHandler("Excel_Cs_ReadRangeV2");
const insert = getHandler("Excel_Cs_Insert");
const sheets = getHandler("Excel_GetSheetsName");
expect(create && write && read && insert && sheets).toBeTruthy();
const handle = await create!(context, node("Excel_Cs_NewV5"), { path: directory, name: "smoke.xlsx", override: "1" }, component);
await write!(context, node("Excel_Cs_WriteCell"), { workbook: handle, sheet: "Sheet1", row: "1", column: "A", content: "RPA", save: "1" }, component);
const table = await read!(context, node("Excel_Cs_ReadRangeV2"), { workbook: handle, sheet: "Sheet1", range: "A1:A1" }, component);
await insert!(context, node("Excel_Cs_Insert"), { workbook: handle, name: "结果", save: "1" }, component);
expect(table).toEqual([["RPA"]]);
await expect(sheets!(context, node("Excel_GetSheetsName"), { workbook: handle }, component)).resolves.toEqual(["Sheet1", "结果"]);
await expect(readFile(path.join(directory, "smoke.xlsx"))).resolves.toBeTruthy();
});
it("counts and concatenates PDF files", async () => {
const directory = await tempDir();
const first = await PDFDocument.create();
first.addPage([120, 120]);
const second = await PDFDocument.create();
second.addPage([120, 120]);
const firstPath = path.join(directory, "one.pdf");
const secondPath = path.join(directory, "two.pdf");
await writeFile(firstPath, await first.save());
await writeFile(secondPath, await second.save());
const count = getHandler("PDF_GetPdfPageNum");
const concat = getHandler("PDF_ConcatPdf");
expect(count && concat).toBeTruthy();
await expect(count!(context, node("PDF_GetPdfPageNum"), { fileInPath: firstPath }, component)).resolves.toBe(1);
const output = await concat!(context, node("PDF_ConcatPdf"), { newPdfPath: directory, newPdfName: "merged.pdf", pdfList: JSON.stringify([firstPath, secondPath]) }, component);
expect(output).toBe(path.join(directory, "merged.pdf"));
const merged = await PDFDocument.load(await readFile(output as string));
expect(merged.getPageCount()).toBe(2);
});
it("executes a local desktop command through the registered bridge", async () => {
const execute = getHandler("System_ExecuteCommand");
expect(execute).toBeTruthy();
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output smoke" }, component)).resolves.toContain("smoke");
});
it("finds an exact PNG template inside a larger screenshot", async () => {
const directory = await tempDir();
const screen = new PNG({ width: 48, height: 36 });
const template = new PNG({ width: 8, height: 8 });
screen.data.fill(255);
for (let y = 0; y < template.height; y += 1) {
for (let x = 0; x < template.width; x += 1) {
const templateIndex = (y * template.width + x) * 4;
template.data[templateIndex] = 220;
template.data[templateIndex + 1] = 20;
template.data[templateIndex + 2] = 30;
template.data[templateIndex + 3] = 255;
const screenIndex = ((12 + y) * screen.width + 17 + x) * 4;
template.data.copy(screen.data, screenIndex, templateIndex, templateIndex + 4);
}
}
const screenPath = path.join(directory, "screen.png");
const templatePath = path.join(directory, "template.png");
await writeFile(screenPath, PNG.sync.write(screen));
await writeFile(templatePath, PNG.sync.write(template));
const match = await findImage(templatePath, screenPath, 0.02);
expect(match).toMatchObject({ x: 17, y: 12, width: 8, height: 8, score: 1 });
});
it("sends mail to a local SMTP server", async () => {
let received = "";
const server = net.createServer((socket) => {
socket.setEncoding("utf8");
socket.write("220 localhost ESMTP\r\n");
let buffer = "";
let dataMode = false;
socket.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\r\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (dataMode) {
if (line === ".") {
dataMode = false;
socket.write("250 queued\r\n");
} else received += `${line}\n`;
} else if (/^EHLO/i.test(line)) socket.write("250-localhost\r\n250 OK\r\n");
else if (/^DATA/i.test(line)) { dataMode = true; socket.write("354 end with dot\r\n"); }
else if (/^QUIT/i.test(line)) { socket.write("221 bye\r\n"); socket.end(); }
else socket.write("250 OK\r\n");
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
try {
const send = getHandler("Mail_Send");
expect(send).toBeTruthy();
await send!(context, node("Mail_Send"), {
host: "127.0.0.1", port: String(port), account: "robot@example.com",
to: "owner@example.com", subject: "RPA smoke", content: "mail delivered", is_ssl: "False"
}, component);
expect(received).toContain("RPA smoke");
expect(received).toContain("mail delivered");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("creates and writes a Word document through COM", async () => {
if (process.platform !== "win32") return;
const directory = await tempDir();
const create = getHandler("Word_New");
const write = getHandler("Word_Write");
expect(create && write).toBeTruthy();
const handle = await create!(context, node("Word_New"), { filePath: directory, name: "smoke.docx" }, component);
await write!(context, node("Word_Write"), { objWord: handle, text: "RPA Word smoke" }, component);
const file = await readFile(path.join(directory, "smoke.docx"));
expect(file.byteLength).toBeGreaterThan(1000);
}, 30000);
});
@@ -0,0 +1,39 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import type { Catalog } from "../../shared/types";
import { hasCapability } from "../engine/registry";
import "./index";
const planned = [
"logic_control_if_else", "logic_control_count_for", "logic_control_array_for_v3", "logic_control_while",
"logic_control_try_catch", "logic_control_var", "logic_control_break_for", "logic_control_next_for",
"logic_control_stop", "Trigger.Manual", "Trigger.FileArrival",
"WebBrowser_CreateV3", "WebBrowser_GoUrl", "WebBrowser_Switch", "Element_Click_V6", "Element_SetValue_V6",
"Element_GetText_V5", "Element_Exists", "Element_SelectOperation_v1", "WebBrowser_RunJsInIeByTitle", "WebBrowser_SaveAs",
"File_Read", "File_Write", "File_Copy", "File_File_Move", "File_DeleteFile", "File_Rename",
"File_FileExists", "File_GetFileList", "File_MakeDir", "File_CompressFile", "File_DecompressFile", "File_GetFileInfo",
"Excel_Cs_NewV5", "Excel_Cs_OpenV4", "Excel_Cs_ReadContentV2", "Excel_Cs_WriteCell", "Excel_Cs_WriteRange",
"Excel_Cs_ReadRangeV2", "Excel_Save", "Excel_Cs_SaveAs", "Excel_GetSheetsName", "Excel_Cs_Insert", "Excel_Cs_Rename", "CSV_Read", "CSV_Write",
"Keyboard_Send_V1", "Keyboard_PressNew", "Mouse_Click", "Mouse_MoveTo", "Mouse_DragTo", "Mouse_RollTo",
"Window_SetActive", "Window_GetActive", "Window_WindowExists", "Window_CloseWindow", "App_Run",
"Clipboard_GetText", "Clipboard_SetText", "Dialog_Notice", "Dialog_MsgBox", "System_ExecuteCommand",
"Http_Get", "Http_Post", "Mail_Send", "Mail_UnionSend",
"PDF_GetPdfPageNum", "PDF_GetPdfText", "PDF_ConcatPdf", "Word_New", "Word_Write",
"Image_Screenshot", "Image_Find", "Image_Click"
];
const runtimeContainers = new Set([
"logic_control_if_else", "logic_control_count_for", "logic_control_array_for_v3", "logic_control_dictionary_for_v1",
"logic_control_while", "infinite_loop", "logic_control_try_catch"
]);
describe("planned capability audit", () => {
it("keeps every planned representative capability executable", async () => {
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
const missing = planned.filter((id) => {
const component = catalog.components.find((item) => item.id === id);
return !component || (!hasCapability(component.id) && !hasCapability(component.functionName) && !runtimeContainers.has(component.id));
});
expect(missing).toEqual([]);
});
});
+163
View File
@@ -0,0 +1,163 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
const execFileAsync = promisify(execFile);
const USER32 = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaUser32 {
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
}
'@`;
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
}
return fallback;
}
function number(args: Record<string, unknown>, keys: string[], fallback = 0): number {
const value = Number(text(args, keys, String(fallback)));
return Number.isFinite(value) ? value : fallback;
}
function point(value: unknown): [number, number] {
if (Array.isArray(value)) return [Number(value[0]) || 0, Number(value[1]) || 0];
try {
const parsed = JSON.parse(String(value || "[0,0]"));
return point(parsed);
} catch {
const values = String(value || "").split(/[ ,,]+/).map(Number);
return [values[0] || 0, values[1] || 0];
}
}
function encoded(command: string): string {
return Buffer.from(command, "utf16le").toString("base64");
}
export async function powershell(command: string, timeout = 30000): Promise<string> {
try {
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(command)], {
windowsHide: true,
timeout,
maxBuffer: 4 * 1024 * 1024
});
return String(result.stdout || "").trim();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
}
export function psString(value: unknown): string {
return `'${String(value ?? "").replace(/'/g, "''")}'`;
}
const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
const keys = text(args, ["keys", "key", "text", "value"]);
if (!keys) throw new RuntimeError("validation_error", "键盘输入为空", { nodeId: node.id, componentId: node.componentId });
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(${psString(keys)})`);
return keys;
};
const mouseClick: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = point(args.position ?? args.point ?? args.location ?? "[0,0]");
const button = text(args, ["side", "button"], "0").toLowerCase();
const down = button.includes("right") || button === "1" ? 0x0008 : 0x0002;
const up = button.includes("right") || button === "1" ? 0x0010 : 0x0004;
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y}); [RpaUser32]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaUser32]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
return { x, y };
};
const mouseMove: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = point(args.position ?? args.point ?? args.location ?? "[0,0]");
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y})`);
return { x, y };
};
const mouseDrag: CapabilityHandler = async (_ctx, node, args) => {
const [sx, sy] = point(args.Start ?? args.start ?? "[0,0]");
const [ex, ey] = point(args.End ?? args.end ?? "[0,0]");
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${sx},${sy}); [RpaUser32]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaUser32]::SetCursorPos(${ex},${ey}); Start-Sleep -Milliseconds 80; [RpaUser32]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)`);
return { start: [sx, sy], end: [ex, ey] };
};
const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
const amount = number(args, ["RollNum", "rollNum", "amount"], 1) * 120;
await powershell(`${USER32}; [RpaUser32]::mouse_event(0x0800,0,0,${amount},[UIntPtr]::Zero)`);
return amount;
};
const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const result = await powershell(`${USER32}; $p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; [RpaUser32]::SetForegroundWindow($p.MainWindowHandle); $p.MainWindowTitle`);
return result;
};
const getActiveWindow: CapabilityHandler = async () => powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`);
const windowExists: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true";
};
const closeWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["window_obj", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`);
};
const runApp: CapabilityHandler = async (_ctx, node, args) => {
const executable = text(args, ["executeable", "executable", "path"]);
if (!executable) throw new RuntimeError("validation_error", "应用路径为空", { nodeId: node.id, componentId: node.componentId });
const params = text(args, ["params", "arguments"]);
const cwd = text(args, ["cwd"]);
const result = await powershell(`$p = Start-Process -FilePath ${psString(executable)} -ArgumentList ${psString(params)} ${cwd ? `-WorkingDirectory ${psString(cwd)}` : ""} -PassThru; $p.Id`);
return Number(result) || result;
};
const clipboardGet: CapabilityHandler = async () => powershell("Get-Clipboard -Raw");
const clipboardSet: CapabilityHandler = async (_ctx, _node, args) => {
const content = text(args, ["text", "value"]);
await powershell(`Set-Clipboard -Value ${psString(content)}`);
return content;
};
const notice: CapabilityHandler = async (_ctx, _node, args) => {
const title = text(args, ["title"], "提醒");
const content = text(args, ["text", "message"]);
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(${psString(content)},${psString(title)}) | Out-Null`);
return true;
};
const executeCommand: CapabilityHandler = async (_ctx, node, args) => {
const command = text(args, ["action", "command"]);
if (!command) throw new RuntimeError("validation_error", "命令行为空", { nodeId: node.id, componentId: node.componentId });
return powershell(command, Math.max(1000, number(args, ["timeoutMs"], 120000)));
};
register(["Keyboard_Send_V1", "Keyboard_Send", "Keyboard_PressNew", "Keyboard_Press"], keyboardSend);
register(["Mouse_Click", "Mouse_ClickNew"], mouseClick);
register(["Mouse_MoveTo", "Mouse_Move"], mouseMove);
register(["Mouse_DragTo", "Mouse_Drag"], mouseDrag);
register(["Mouse_RollTo", "Mouse_Scroll"], mouseRoll);
register(["Window_SetActive", "Window_Activate"], setActiveWindow);
register(["Window_GetActive", "Window_GetActiveWindow"], getActiveWindow);
register(["Window_WindowExists", "Window_Exists"], windowExists);
register(["Window_CloseWindow", "Window_Close"], closeWindow);
register(["App_Run", "App_Start"], runApp);
register("Clipboard_GetText", clipboardGet);
register("Clipboard_SetText", clipboardSet);
register("Dialog_Notice", notice);
register("Dialog_MsgBox", notice);
register("System_ExecuteCommand", executeCommand);
+107
View File
@@ -0,0 +1,107 @@
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./extended";
const directories: string[] = [];
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (componentId: string): FlowNode => ({
id: `extended_${componentId}`,
componentId,
position: { x: 0, y: 0 },
values: {},
options: {}
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-extended-"));
directories.push(directory);
return directory;
}
async function run(componentId: string, args: Record<string, unknown>): Promise<unknown> {
const handler = getHandler(componentId);
expect(handler, `${componentId} should be registered`).toBeTruthy();
return handler!(context, node(componentId), args, component);
}
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("extended local capabilities", () => {
it("registers every audited component id", () => {
for (const id of [
"File_GetFileList_V1", "File_GetFolderList", "File_GetFolderList_V1", "File_MoveFolder",
"File_Write_V1", "File_Copy_V1", "File_Rename_V1", "File_ReadXml", "File_EmptyFolder",
"Bivartable_ReadDfShape", "Bivartable_SortDf", "Bivartable_DoSort", "Bivartable_DelDfColRow",
"Bivartable_ReadTable", "Excel_Cs_ReadCell", "Excel_GetRowsCount", "Excel_GetColumnsCount",
"Excel_Cs_GetEditRange"
]) expect(getHandler(id), id).toBeTruthy();
});
it("performs file listing, copy, rename, XML read, move, and empty operations", async () => {
const root = await temporaryDirectory();
const source = path.join(root, "source");
const nested = path.join(source, "nested");
await run("File_Write_V1", { path: path.join(source, "alpha.xml"), content: "<root>RPA</root>" });
await run("File_Write_V1", { path: path.join(nested, "beta.txt"), content: "beta" });
await expect(run("File_GetFileList_V1", { folder_path: source, recursive: "1", is_full_path: "0" }))
.resolves.toEqual(["alpha.xml", "beta.txt"]);
await expect(run("File_GetFolderList", { folder_path: source, recursive: "1" }))
.resolves.toEqual([nested]);
await expect(run("File_ReadXml", { path: path.join(source, "alpha.xml") }))
.resolves.toBe("<root>RPA</root>");
const copied = await run("File_Copy_V1", {
file_type: "file", file_path: path.join(source, "alpha.xml"), target_path: root,
rename: "1", name: "copied.xml", action: "1"
}) as string;
await expect(readFile(copied, "utf8")).resolves.toContain("RPA");
const renamed = await run("File_Rename_V1", { file_type: "file", file_path: copied, name: "renamed.xml", action: "1" }) as string;
await expect(readFile(renamed, "utf8")).resolves.toContain("RPA");
const moved = path.join(root, "moved");
await run("File_MoveFolder", { pathSrc: nested, pathDest: moved, overwrite: "True" });
await expect(readFile(path.join(moved, "beta.txt"), "utf8")).resolves.toBe("beta");
await run("File_EmptyFolder", { folder_path: moved });
await expect(readdir(moved)).resolves.toEqual([]);
});
it("sorts, measures, deletes, and selects two-dimensional data", async () => {
const input = [["name", "score"], ["B", 2], ["A", 3], ["C", 1]];
await expect(run("Bivartable_ReadDfShape", { df: input })).resolves.toEqual([4, 2]);
await expect(run("Bivartable_SortDf", { df: input, name: "score", sort: "True" }))
.resolves.toEqual([["name", "score"], ["C", 1], ["B", 2], ["A", 3]]);
await expect(run("Bivartable_DoSort", { df: [[3], [1], [2]], sortby: "1", basceeding: "False" }))
.resolves.toEqual([[3], [2], [1]]);
await expect(run("Bivartable_DelDfColRow", { df: input, delRange: "col", delType: "name", del_col_name_list: ["score"] }))
.resolves.toEqual([["name"], ["B"], ["A"], ["C"]]);
await expect(run("Bivartable_ReadTable", { df: input, start_row: "2", end_row: "3", start_column: "A", end_column: "B" }))
.resolves.toEqual([["B", 2], ["A", 3]]);
});
it("reads Excel cells, dimensions, and the edited range", async () => {
const root = await temporaryDirectory();
const filePath = path.join(root, "extended.xlsx");
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("Data");
sheet.getCell("B2").value = "RPA";
sheet.getCell("D5").value = 42;
await workbook.xlsx.writeFile(filePath);
await expect(run("Excel_Cs_ReadCell", { workbook: filePath, sheet: "Data", column: "B", row: "2" })).resolves.toBe("RPA");
await expect(run("Excel_GetRowsCount", { objExcel: filePath, sheet: "Data" })).resolves.toBe(5);
await expect(run("Excel_GetColumnsCount", { objExcel: filePath, sheet: "Data" })).resolves.toBe(4);
await expect(run("Excel_Cs_GetEditRange", { workbook: { path: filePath }, sheet: "Data" })).resolves.toEqual({
startRow: 2, startColumn: 2, endRow: 5, endColumn: 4, range: "B2:D5"
});
});
});
+284
View File
@@ -0,0 +1,284 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import ExcelJS from "exceljs";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
type Table = unknown[][];
function value(args: Record<string, unknown>, keys: string[], fallback?: unknown): unknown {
for (const key of keys) {
const candidate = args[key];
if (candidate !== undefined && candidate !== null && candidate !== "") return candidate;
}
return fallback;
}
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
return String(value(args, keys, fallback));
}
function bool(candidate: unknown, fallback = false): boolean {
if (candidate === undefined || candidate === null || candidate === "") return fallback;
return [true, 1, "1", "true", "True", "是", "开启"].includes(candidate as never);
}
function requiredPath(candidate: unknown, label: string, nodeId: string, componentId: string): string {
const result = String(candidate ?? "").trim();
if (!result) {
throw new RuntimeError("validation_error", `${label}为空`, {
nodeId,
componentId,
recommendation: `请配置${label}`
});
}
return path.resolve(result);
}
function list(candidate: unknown): unknown[] {
if (Array.isArray(candidate)) return candidate;
if (candidate === undefined || candidate === null || candidate === "") return [];
if (typeof candidate === "string") {
try {
const parsed = JSON.parse(candidate);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return candidate.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean);
}
}
return [candidate];
}
function table(candidate: unknown): Table {
const parsed = typeof candidate === "string"
? (() => {
try { return JSON.parse(candidate); } catch { return candidate; }
})()
: candidate;
if (!Array.isArray(parsed)) return parsed === undefined || parsed === null ? [] : [[parsed]];
return parsed.map((row) => Array.isArray(row) ? [...row] : [row]);
}
function columnNumber(candidate: unknown): number {
const source = String(candidate ?? "A").trim().toUpperCase();
if (/^\d+$/.test(source)) return Math.max(1, Number(source));
let result = 0;
for (const character of source) result = result * 26 + character.charCodeAt(0) - 64;
return Math.max(1, result);
}
function compare(left: unknown, right: unknown): number {
if (typeof left === "number" && typeof right === "number") return left - right;
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN", { numeric: true });
}
async function ensureAbsentOrRemove(target: string, action: unknown): Promise<boolean> {
try {
await fs.access(target);
} catch {
return true;
}
const mode = String(action ?? "1");
if (mode === "2" || mode.toLowerCase() === "skip") return false;
if (mode === "3" || mode.toLowerCase() === "error") throw new Error(`目标已存在:${target}`);
await fs.rm(target, { recursive: true, force: true });
return true;
}
async function collectEntries(
root: string,
kind: "file" | "directory",
recursive: boolean,
keyword: string,
ignore: string
): Promise<string[]> {
const results: string[] = [];
async function walk(folder: string): Promise<void> {
const entries = await fs.readdir(folder, { withFileTypes: true });
for (const entry of entries) {
const absolute = path.join(folder, entry.name);
if (entry.isDirectory()) {
if (kind === "directory" && (!keyword || entry.name.includes(keyword)) && (!ignore || !entry.name.includes(ignore))) {
results.push(absolute);
}
if (recursive) await walk(absolute);
} else if (kind === "file" && (!keyword || entry.name.includes(keyword)) && (!ignore || !entry.name.includes(ignore))) {
results.push(absolute);
}
}
}
await walk(root);
return results;
}
register("File_GetFileList_V1", async (_ctx, node, args) => {
const root = requiredPath(value(args, ["folder_path"]), "文件夹", node.id, node.componentId);
const fullPath = bool(args.is_full_path, true);
const results = await collectEntries(root, "file", bool(args.recursive), text(args, ["key_word"]), text(args, ["ignore"]));
const sortDescending = ["2", "desc", "descending"].includes(String(args.sort_type ?? "1").toLowerCase());
results.sort((left, right) => left.localeCompare(right, "zh-CN", { numeric: true }) * (sortDescending ? -1 : 1));
return fullPath ? results : results.map((item) => path.basename(item));
});
const getFolderList: CapabilityHandler = async (_ctx, node, args) => {
const root = requiredPath(value(args, ["folder_path"]), "文件夹", node.id, node.componentId);
const results = await collectEntries(root, "directory", bool(args.recursive), "", text(args, ["ignore"]));
return results.sort((left, right) => left.localeCompare(right, "zh-CN", { numeric: true }));
};
register(["File_GetFolderList", "File_GetFolderList_V1"], getFolderList);
register("File_MoveFolder", async (_ctx, node, args) => {
const source = requiredPath(value(args, ["pathSrc"]), "文件夹路径", node.id, node.componentId);
const target = requiredPath(value(args, ["pathDest"]), "目的地路径", node.id, node.componentId);
if (bool(args.overwrite)) await fs.rm(target, { recursive: true, force: true });
await fs.mkdir(path.dirname(target), { recursive: true });
try {
await fs.rename(source, target);
} catch {
await fs.cp(source, target, { recursive: true, force: bool(args.overwrite) });
await fs.rm(source, { recursive: true, force: true });
}
return target;
});
register("File_Write_V1", async (_ctx, node, args) => {
const target = requiredPath(value(args, ["path"]), "文件路径", node.id, node.componentId);
await fs.mkdir(path.dirname(target), { recursive: true });
const content = String(args.content ?? "");
if (bool(args.append)) await fs.appendFile(target, content, "utf8");
else await fs.writeFile(target, content, "utf8");
return target;
});
register("File_Copy_V1", async (_ctx, node, args) => {
const isFolder = text(args, ["file_type"], "file").toLowerCase() === "folder";
const source = requiredPath(value(args, [isFolder ? "folder_path" : "file_path"]), isFolder ? "待复制文件夹" : "待复制文件", node.id, node.componentId);
const destination = requiredPath(value(args, ["target_path"]), "复制目标", node.id, node.componentId);
const newName = bool(args.rename) && text(args, ["name"]) ? text(args, ["name"]) : path.basename(source);
const target = path.join(destination, newName);
if (!await ensureAbsentOrRemove(target, args.action)) return target;
await fs.mkdir(destination, { recursive: true });
if (isFolder) await fs.cp(source, target, { recursive: true, force: true });
else await fs.copyFile(source, target);
return target;
});
register("File_Rename_V1", async (_ctx, node, args) => {
const isFolder = text(args, ["file_type"], "file").toLowerCase() === "folder";
const source = requiredPath(value(args, [isFolder ? "folder_path" : "file_path"]), "待重命名路径", node.id, node.componentId);
const name = text(args, ["name"]).trim();
if (!name || path.basename(name) !== name) throw new Error("新名称必须是单独的文件或文件夹名称");
const target = path.join(path.dirname(source), name);
if (!await ensureAbsentOrRemove(target, args.action)) return source;
await fs.rename(source, target);
return target;
});
register("File_ReadXml", async (_ctx, node, args) => {
const target = requiredPath(value(args, ["path"]), "XML文件路径", node.id, node.componentId);
const buffer = await fs.readFile(target);
const encoding = text(args, ["encode"], "utf-8").toLowerCase().replace("_", "-");
if (encoding === "utf-8" || encoding === "utf8" || encoding === "auto") return buffer.toString("utf8");
return new TextDecoder(encoding).decode(buffer);
});
register("File_EmptyFolder", async (_ctx, node, args) => {
const folder = requiredPath(value(args, ["folder_path"]), "待清空文件夹", node.id, node.componentId);
const entries = await fs.readdir(folder);
await Promise.all(entries.map((entry) => fs.rm(path.join(folder, entry), { recursive: true, force: true })));
return true;
});
register("Bivartable_ReadDfShape", (_ctx, _node, args) => {
const rows = table(value(args, ["df"]));
return [rows.length, rows.reduce((maximum, row) => Math.max(maximum, row.length), 0)];
});
function sortTable(args: Record<string, unknown>): Table {
const rows = table(value(args, ["df"]));
if (rows.length < 2) return rows;
const requested = text(args, ["name", "sortby"]).split(/[,,]/)[0].trim();
const headerIndex = requested ? rows[0].findIndex((cell) => String(cell) === requested) : -1;
const index = headerIndex >= 0 ? headerIndex : columnNumber(requested || "1") - 1;
const ascending = bool(value(args, ["sort", "basceeding"]), true);
const hasHeader = headerIndex >= 0;
const body = (hasHeader ? rows.slice(1) : rows).sort((left, right) => compare(left[index], right[index]) * (ascending ? 1 : -1));
return hasHeader ? [rows[0], ...body] : body;
}
register(["Bivartable_SortDf", "Bivartable_DoSort"], (_ctx, _node, args) => sortTable(args));
register("Bivartable_DelDfColRow", (_ctx, _node, args) => {
const rows = table(value(args, ["df"]));
const deletingColumns = text(args, ["delRange"], "col").toLowerCase().includes("col");
const raw = deletingColumns
? value(args, [text(args, ["delType"]) === "name" ? "del_col_name_list" : "del_col_num_list"])
: value(args, [text(args, ["delType"]) === "name" ? "del_row_name_list" : "del_row_num_list"]);
const requested = list(raw);
if (deletingColumns) {
const indexes = new Set(requested.map((item) => {
const byName = rows[0]?.findIndex((cell) => String(cell) === String(item)) ?? -1;
return text(args, ["delType"]) === "name" ? byName : columnNumber(item) - 1;
}).filter((index) => index >= 0));
return rows.map((row) => row.filter((_cell, index) => !indexes.has(index)));
}
const indexes = new Set(requested.map((item) => Math.max(0, Number(item) - 1)));
return rows.filter((_row, index) => !indexes.has(index));
});
register("Bivartable_ReadTable", (_ctx, _node, args) => {
const rows = table(value(args, ["df"]));
const startRow = Math.max(1, Number(args.start_row ?? args.row ?? 1)) - 1;
const endRow = Math.max(startRow + 1, Number(args.end_row ?? args.row ?? rows.length));
const startColumn = columnNumber(args.start_column ?? args.column ?? "A") - 1;
const endColumn = Math.max(startColumn + 1, columnNumber(args.end_column ?? args.column ?? String(rows[0]?.length || 1)));
const selection = rows.slice(startRow, endRow).map((row) => row.slice(startColumn, endColumn));
if (selection.length === 1 && selection[0].length === 1) return selection[0][0];
return selection;
});
function workbookPath(args: Record<string, unknown>, nodeId: string, componentId: string): string {
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "path", "file"]);
const candidate = raw && typeof raw === "object" ? (raw as { path?: unknown }).path : raw;
return requiredPath(candidate, "Excel文件路径", nodeId, componentId);
}
async function loadSheet(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<ExcelJS.Worksheet> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(workbookPath(args, nodeId, componentId));
const name = text(args, ["sheet", "sheetName"]);
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
return sheet;
}
function excelValue(candidate: ExcelJS.CellValue): unknown {
if (candidate && typeof candidate === "object" && "result" in candidate) return candidate.result;
if (candidate && typeof candidate === "object" && "text" in candidate) return candidate.text;
return candidate ?? "";
}
register("Excel_Cs_ReadCell", async (_ctx, node, args) => {
const sheet = await loadSheet(args, node.id, node.componentId);
return excelValue(sheet.getCell(`${text(args, ["column"], "A")}${text(args, ["row"], "1")}`).value);
});
register("Excel_GetRowsCount", async (_ctx, node, args) => (await loadSheet(args, node.id, node.componentId)).rowCount);
register("Excel_GetColumnsCount", async (_ctx, node, args) => (await loadSheet(args, node.id, node.componentId)).columnCount);
register("Excel_Cs_GetEditRange", async (_ctx, node, args) => {
const sheet = await loadSheet(args, node.id, node.componentId);
let startRow = Number.POSITIVE_INFINITY;
let startColumn = Number.POSITIVE_INFINITY;
let endRow = 0;
let endColumn = 0;
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
row.eachCell({ includeEmpty: false }, (_cell, columnNumberValue) => {
startRow = Math.min(startRow, rowNumber);
startColumn = Math.min(startColumn, columnNumberValue);
endRow = Math.max(endRow, rowNumber);
endColumn = Math.max(endColumn, columnNumberValue);
});
});
if (!endRow) return { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0, range: "" };
const range = `${sheet.getColumn(startColumn).letter}${startRow}:${sheet.getColumn(endColumn).letter}${endRow}`;
return { startRow, startColumn, endRow, endColumn, range };
});
+7
View File
@@ -3,5 +3,12 @@ import "./files";
import "./http";
import "./data";
import "./browser";
import "./office";
import "./desktop";
import "./pdf";
import "./word";
import "./message";
import "./vision";
import "./extended";
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
+31
View File
@@ -0,0 +1,31 @@
import nodemailer from "nodemailer";
import { register } from "../engine/registry";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
function list(value: string): string[] {
return value.split(/[;,,;\s]+/).map((item) => item.trim()).filter(Boolean);
}
register(["Mail_Send", "Mail_UnionSend"], async (_ctx, node, args) => {
const account = text(args, ["account", "from", "account_obj"]);
const password = text(args, ["password", "pass"]);
const host = text(args, ["host", "smtp"]);
if (!host || !account) throw new Error(`邮件配置不完整(${node.componentId})`);
const port = Number(text(args, ["port"], "25"));
const secure = ["true", "1", "是", "True"].includes(text(args, ["is_ssl"], "False"));
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
const result = await transporter.sendMail({
from: account,
to: list(text(args, ["to", "receiver"])),
cc: list(text(args, ["Cc", "cc"])),
bcc: list(text(args, ["Bcc", "bcc"])),
subject: text(args, ["subject"]),
text: text(args, ["content", "body"]),
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
});
return { messageId: result.messageId, accepted: result.accepted };
});
+222
View File
@@ -0,0 +1,222 @@
import ExcelJS from "exceljs";
import { promises as fs } from "node:fs";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
interface ExcelHandle {
id: string;
path: string;
}
const workbooks = new Map<string, ExcelJS.Workbook>();
function value(args: Record<string, unknown>, keys: string[], fallback = ""): unknown {
for (const key of keys) {
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return args[key];
}
return fallback;
}
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
return String(value(args, keys, fallback));
}
function bool(valueToRead: unknown, fallback = false): boolean {
if (valueToRead === undefined || valueToRead === null || valueToRead === "") return fallback;
return [true, "true", "1", 1, "是", "开启"].includes(valueToRead as never);
}
function required(valueToRead: unknown, label: string, nodeId: string, componentId: string): string {
const result = String(valueToRead || "").trim();
if (!result) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId, recommendation: `请配置${label}` });
return result;
}
function newHandle(filePath: string, workbook: ExcelJS.Workbook): ExcelHandle {
const handle = { id: `excel_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, path: filePath };
workbooks.set(handle.id, workbook);
return handle;
}
async function resolveWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<{ handle: ExcelHandle; workbook: ExcelJS.Workbook }> {
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]);
const id = typeof raw === "object" && raw !== null ? String((raw as Partial<ExcelHandle>).id || "") : String(raw || "");
const existing = id ? workbooks.get(id) : undefined;
if (existing) return { handle: { id, path: String((raw as Partial<ExcelHandle>).path || "") }, workbook: existing };
const filePath = required(typeof raw === "object" ? (raw as Partial<ExcelHandle>).path : raw, "Excel文件路径", nodeId, componentId);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(filePath);
return { handle: newHandle(filePath, workbook), workbook };
}
function sheetOf(workbook: ExcelJS.Workbook, name: string): ExcelJS.Worksheet {
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
return sheet;
}
function parseRange(range: string, sheet: ExcelJS.Worksheet): { start: string; end?: string } {
if (range.trim()) {
const [start, end] = range.split(":").map((item) => item.trim());
return { start, end };
}
const lastRow = Math.max(1, sheet.rowCount);
const lastColumn = Math.max(1, sheet.columnCount);
return { start: "A1", end: `${sheet.getColumn(lastColumn).letter}${lastRow}` };
}
function cellValue(valueToRead: ExcelJS.CellValue): unknown {
if (valueToRead && typeof valueToRead === "object" && "text" in valueToRead) return valueToRead.text;
if (valueToRead && typeof valueToRead === "object" && "result" in valueToRead) return valueToRead.result;
return valueToRead ?? "";
}
function tableFromSheet(sheet: ExcelJS.Worksheet, range: string): unknown[][] {
const target = parseRange(range, sheet);
const startRow = Number(sheet.getCell(target.start).row);
const endRow = target.end ? Number(sheet.getCell(target.end).row) : sheet.rowCount;
const rows = sheet.getRows(startRow, Math.max(1, endRow - startRow + 1)) || [];
const startColumn = Number(sheet.getCell(target.start).col);
const endColumn = target.end ? Number(sheet.getCell(target.end).col) : sheet.columnCount;
return rows.map((row) => {
const values: unknown[] = [];
for (let column = startColumn; column <= endColumn; column += 1) values.push(cellValue(row.getCell(column).value));
return values;
});
}
function rowsFrom(valueToRead: unknown): unknown[][] {
if (Array.isArray(valueToRead)) {
return valueToRead.map((row) => Array.isArray(row) ? row : [row]);
}
if (typeof valueToRead === "string") {
try {
return rowsFrom(JSON.parse(valueToRead));
} catch {
return [[valueToRead]];
}
}
return [[valueToRead]];
}
const newExcel: CapabilityHandler = async (_ctx, node, args) => {
const folder = required(value(args, ["path", "folder"]), "Excel目录", node.id, node.componentId);
const name = text(args, ["name"], "新建工作簿.xlsx");
const filePath = path.resolve(folder, path.extname(name) ? name : `${name}.xlsx`);
if (!bool(value(args, ["override"]), true)) {
try {
await fs.access(filePath);
throw new RuntimeError("validation_error", `文件已存在:${filePath}`, { nodeId: node.id, componentId: node.componentId, recommendation: "开启覆盖同名文件,或修改文件名称" });
} catch (error) {
if (error instanceof RuntimeError) throw error;
}
}
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet("Sheet1");
await fs.mkdir(path.dirname(filePath), { recursive: true });
await workbook.xlsx.writeFile(filePath);
return newHandle(filePath, workbook);
};
const openExcel: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(value(args, ["file", "path"]), "Excel文件", node.id, node.componentId);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(path.resolve(filePath));
return newHandle(path.resolve(filePath), workbook);
};
const readExcel: CapabilityHandler = async (_ctx, node, args) => {
const { workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
return tableFromSheet(sheet, text(args, ["range", "excelRng"]));
};
const writeCell: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const address = `${text(args, ["column"], "A")}${text(args, ["row"], "1")}`;
sheet.getCell(address).value = value(args, ["content", "value"]) as ExcelJS.CellValue;
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
};
const writeRange: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const start = `${text(args, ["column"], "A")}${text(args, ["row"], "1")}`;
const startCell = sheet.getCell(start);
const startRow = startCell.row;
const startColumn = startCell.col;
for (const [rowIndex, row] of rowsFrom(value(args, ["contents", "table"])).entries()) {
for (const [columnIndex, cell] of row.entries()) sheet.getCell(startRow + rowIndex, startColumn + columnIndex).value = cell as ExcelJS.CellValue;
}
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
};
const saveExcel: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
await workbook.xlsx.writeFile(handle.path);
return handle.path;
};
const saveAsExcel: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const target = required(value(args, ["path", "file", "savePath"]), "另存为路径", node.id, node.componentId);
handle.path = path.resolve(target);
await fs.mkdir(path.dirname(handle.path), { recursive: true });
await workbook.xlsx.writeFile(handle.path);
return handle;
};
register(["Excel_Cs_NewV5", "Excel_Cs_New", "Excel_New"], newExcel);
register(["Excel_Cs_OpenV4", "Excel_Cs_Open", "Excel_Open"], openExcel);
register(["Excel_Cs_ReadContentV2", "Excel_Cs_ReadRangeV2", "Excel_ReadRange"], readExcel);
register(["Excel_Cs_WriteCell", "Excel_WriteCell"], writeCell);
register(["Excel_Cs_WriteRange", "Excel_WriteRange"], writeRange);
register(["Excel_Save", "Excel_Cs_Save", "Excel_SaveV2"], saveExcel);
register(["Excel_Cs_SaveAs", "Excel_SaveAs"], saveAsExcel);
register(["Excel_GetSheetsName", "Excel_GetSheetNames"], async (_ctx, node, args) => {
const { workbook } = await resolveWorkbook(args, node.id, node.componentId);
return workbook.worksheets.map((sheet) => sheet.name);
});
register(["Excel_Cs_Rename", "Excel_RenameSheet"], async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const oldName = required(value(args, ["sheet", "oldName"]), "原 Sheet 名称", node.id, node.componentId);
const newName = required(value(args, ["newName", "name", "sheetName"]), "新 Sheet 名称", node.id, node.componentId);
const sheet = sheetOf(workbook, oldName);
sheet.name = newName;
await workbook.xlsx.writeFile(handle.path);
return handle;
});
register(["Excel_Cs_Insert", "Excel_InsertSheet"], async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const name = required(value(args, ["name", "sheetName"]), "Sheet名称", node.id, node.componentId);
if (workbook.getWorksheet(name)) throw new RuntimeError("validation_error", `Sheet 已存在:${name}`, { nodeId: node.id, componentId: node.componentId });
workbook.addWorksheet(name);
if (bool(value(args, ["save"]), false)) await workbook.xlsx.writeFile(handle.path);
return handle;
});
register("Excel_Cs_InsertRow", async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const index = Math.max(1, Number(value(args, ["index"], "1")) || 1);
const count = Math.max(1, Number(value(args, ["count"], "1")) || 1);
const rows = rowsFrom(value(args, ["contents"]));
const inserts = Array.from({ length: count }, (_, rowIndex) => rows[rowIndex] || []);
sheet.spliceRows(index, 0, ...inserts);
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
});
register("Excel_Cs_InsertColumn", async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const index = Math.max(1, Number(value(args, ["index"], "1")) || 1);
const count = Math.max(1, Number(value(args, ["count"], "1")) || 1);
const values = rowsFrom(value(args, ["contents"])).flat();
const inserts = Array.from({ length: count }, (_, columnIndex) => [values[columnIndex] ?? ""]);
sheet.spliceColumns(index, 0, ...inserts);
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
});
+62
View File
@@ -0,0 +1,62 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { PDFDocument } from "pdf-lib";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
function required(value: string, label: string, nodeId: string, componentId: string): string {
if (!value.trim()) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId });
return path.resolve(value);
}
const pageCount: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true });
return document.getPageCount();
};
const concat: CapabilityHandler = async (_ctx, node, args) => {
const folder = required(text(args, ["newPdfPath", "folder", "path"]), "输出目录", node.id, node.componentId);
const name = text(args, ["newPdfName", "name"], "合并结果.pdf");
const output = path.join(folder, path.extname(name) ? name : `${name}.pdf`);
const rawList = text(args, ["pdfList", "files"]);
let files: string[] = [];
try {
const parsed = JSON.parse(rawList);
files = Array.isArray(parsed) ? parsed.map(String) : [String(parsed)];
} catch {
files = rawList.split(/[\r\n;,]+/).map((item) => item.trim()).filter(Boolean);
}
if (!files.length) throw new RuntimeError("validation_error", "待合并 PDF 列表为空", { nodeId: node.id, componentId: node.componentId });
const target = await PDFDocument.create();
for (const file of files) {
const source = await PDFDocument.load(await fs.readFile(path.resolve(file)), { ignoreEncryption: true });
const pages = await target.copyPages(source, source.getPageIndices());
pages.forEach((page) => target.addPage(page));
}
await fs.mkdir(path.dirname(output), { recursive: true });
await fs.writeFile(output, await target.save());
return output;
};
register(["PDF_GetPdfPageNum", "PDF_GetPageCount"], pageCount);
register("PDF_ConcatPdf", concat);
register("PDF_GetPdfText", async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const document = await pdfjs.getDocument({ data: new Uint8Array(await fs.readFile(filePath)) }).promise;
const pages = text(args, ["pdfNum", "page"]);
const indices = pages ? pages.split(/[ ,,;;]+/).map((item) => Number(item)).filter((item) => item > 0) : Array.from({ length: document.numPages }, (_, index) => index + 1);
const chunks: string[] = [];
for (const index of indices) {
const page = await document.getPage(index);
const content = await page.getTextContent();
chunks.push(content.items.map((item) => ("str" in item ? item.str : "")).join(""));
}
return chunks.join("\n");
});
+97
View File
@@ -0,0 +1,97 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { PNG } from "pngjs";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell, psString } from "./desktop";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
register("Image_Screenshot", async (_ctx, node, args) => {
const folder = text(args, ["outImagePath", "folder"]);
const name = text(args, ["outImageName", "name"], `screenshot_${Date.now()}.png`);
const output = path.resolve(text(args, ["image_path"], folder ? path.join(folder, name) : name));
await fs.mkdir(path.dirname(output), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bitmap = New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height; $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size); $bitmap.Save(${psString(output)},[System.Drawing.Imaging.ImageFormat]::Png); $graphics.Dispose(); $bitmap.Dispose()`);
return output;
});
interface MatchResult { x: number; y: number; width: number; height: number; score: number; }
async function captureScreen(target: string): Promise<string> {
await fs.mkdir(path.dirname(target), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bitmap = New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height; $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size); $bitmap.Save(${psString(target)},[System.Drawing.Imaging.ImageFormat]::Png); $graphics.Dispose(); $bitmap.Dispose()`);
return target;
}
function pixelDifference(screen: PNG, template: PNG, offsetX: number, offsetY: number, sampleStep: number, cutoff: number): number {
let difference = 0;
let samples = 0;
for (let y = 0; y < template.height; y += sampleStep) {
for (let x = 0; x < template.width; x += sampleStep) {
const sourceIndex = ((offsetY + y) * screen.width + offsetX + x) * 4;
const templateIndex = (y * template.width + x) * 4;
difference += Math.abs(screen.data[sourceIndex] - template.data[templateIndex]);
difference += Math.abs(screen.data[sourceIndex + 1] - template.data[templateIndex + 1]);
difference += Math.abs(screen.data[sourceIndex + 2] - template.data[templateIndex + 2]);
samples += 3;
if (difference > cutoff * samples) return difference / (samples * 255);
}
}
return samples ? difference / (samples * 255) : 1;
}
export async function findImage(templatePath: string, screenshotPath: string, accuracy: number): Promise<MatchResult | null> {
const [screenBuffer, templateBuffer] = await Promise.all([fs.readFile(screenshotPath), fs.readFile(templatePath)]);
const screen = PNG.sync.read(screenBuffer);
const template = PNG.sync.read(templateBuffer);
if (template.width > screen.width || template.height > screen.height) return null;
const maximumDifference = Math.max(0.01, Math.min(0.95, accuracy || 0.2));
const sampleStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 18));
const scanStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 12));
let best: MatchResult | null = null;
for (let y = 0; y <= screen.height - template.height; y += scanStep) {
for (let x = 0; x <= screen.width - template.width; x += scanStep) {
const difference = pixelDifference(screen, template, x, y, sampleStep, best ? 1 - best.score : maximumDifference);
const score = 1 - difference;
if (!best || score > best.score) best = { x, y, width: template.width, height: template.height, score };
}
}
if (!best || 1 - best.score > maximumDifference) return null;
const refine = Math.max(1, scanStep);
const minX = Math.max(0, best.x - refine);
const maxX = Math.min(screen.width - template.width, best.x + refine);
const minY = Math.max(0, best.y - refine);
const maxY = Math.min(screen.height - template.height, best.y + refine);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const difference = pixelDifference(screen, template, x, y, sampleStep, 1 - best.score);
const score = 1 - difference;
if (score > best.score) best = { x, y, width: template.width, height: template.height, score };
}
}
return best;
}
async function locate(args: Record<string, unknown>, evidenceDir: string, nodeId: string): Promise<MatchResult> {
const rawTemplate = text(args, ["image", "image_path", "template"]);
if (!rawTemplate.trim()) throw new RuntimeError("validation_error", "模板图片路径为空", { nodeId });
const template = path.resolve(rawTemplate);
const screenshot = path.join(evidenceDir, `${nodeId}_screen.png`);
await captureScreen(screenshot);
const result = await findImage(template, screenshot, Number(text(args, ["accuracy"], "0.2")));
if (!result) throw new RuntimeError("not_found", "屏幕中未找到目标图像", { nodeId, recommendation: "重新截取模板,或调低匹配精度" });
return result;
}
register(["Image_Find", "Image_Find_V1"], async (ctx, node, args) => locate(args, ctx.evidenceDir, node.id));
register(["Image_Click", "Image_Click_V1", "Image_Click_V2", "Image_Click_V3"], async (ctx, node, args) => {
const result = await locate(args, ctx.evidenceDir, node.id);
const x = Math.round(result.x + result.width / 2);
const y = Math.round(result.y + result.height / 2);
await powershell(`Add-Type @'\nusing System; using System.Runtime.InteropServices; public static class Clicker { [DllImport("user32.dll")] public static extern bool SetCursorPos(int X,int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint f,uint x,uint y,uint d,UIntPtr e); }\n'@; [Clicker]::SetCursorPos(${x},${y}); [Clicker]::mouse_event(2,0,0,0,[UIntPtr]::Zero); [Clicker]::mouse_event(4,0,0,0,[UIntPtr]::Zero)`);
return { ...result, click: { x, y } };
});
+44
View File
@@ -0,0 +1,44 @@
import path from "node:path";
import { register } from "../engine/registry";
import { powershell, psString } from "./desktop";
interface WordHandle { path: string; }
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
function handlePath(args: Record<string, unknown>): string {
const raw = args.objWord ?? args.filePath ?? args.path ?? args.file;
if (typeof raw === "object" && raw !== null && "path" in raw) return path.resolve(String((raw as WordHandle).path));
return path.resolve(String(raw || ""));
}
function ensure(value: string, label: string): string {
if (!value.trim()) throw new Error(`${label}为空`);
return value;
}
function wordScript(body: string): string {
return `$word = $null; $doc = $null; try { $word = New-Object -ComObject Word.Application; $word.Visible = $false; $word.DisplayAlerts = 0; ${body} } finally { if ($doc -ne $null) { try { $doc.Close() } catch {} ; try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc) } catch {} }; if ($word -ne $null) { try { $word.Quit() } catch {} ; try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($word) } catch {} }; [GC]::Collect(); [GC]::WaitForPendingFinalizers() }`;
}
register("Word_New", async (_ctx, _node, args) => {
const folder = ensure(text(args, ["filePath", "folder", "path"]), "Word目录");
const name = text(args, ["name"], "新建文档.docx");
const filePath = path.resolve(folder, path.extname(name) ? name : `${name}.docx`);
await powershell(wordScript(`$doc = $word.Documents.Add(); $doc.SaveAs2(${psString(filePath)}); ${psString(filePath)}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_Open", async (_ctx, _node, args) => ({ path: handlePath(args) } satisfies WordHandle));
register("Word_Write", async (_ctx, _node, args) => {
const filePath = ensure(handlePath(args), "Word文件路径");
const content = text(args, ["text", "content"]);
await powershell(wordScript(`$doc = $word.Documents.Open(${psString(filePath)}); $range = $doc.Content; $range.Collapse(0); $range.InsertAfter(${psString(content)}); $doc.Save()`));
return { path: filePath } satisfies WordHandle;
});
register(["Word_Save", "Word_SaveAs"], async (_ctx, _node, args) => ({ path: handlePath(args) } satisfies WordHandle));
+11 -1
View File
@@ -6,6 +6,16 @@ import { hasCapability } from "./engine/registry";
let cached: Catalog | null = null;
const RUNTIME_CONTAINERS = new Set([
"logic_control_if_else",
"logic_control_count_for",
"logic_control_array_for_v3",
"logic_control_dictionary_for_v1",
"logic_control_while",
"infinite_loop",
"logic_control_try_catch"
]);
function resolveCatalogPath(): string {
const candidates = [
process.env.RPA_CATALOG_PATH,
@@ -29,7 +39,7 @@ export function loadCatalog(): Catalog {
const catalog = JSON.parse(raw) as Catalog;
catalog.components = catalog.components.map((component: CatalogComponent) => ({
...component,
implemented: hasCapability(component.id) || hasCapability(component.functionName)
implemented: RUNTIME_CONTAINERS.has(component.id) || hasCapability(component.id) || hasCapability(component.functionName)
}));
cached = catalog;
return catalog;
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowDefinition } from "../../shared/types";
import { register } from "./registry";
import { runFlow } from "./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: [], implemented: true
} 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"]);
});
});
+292
View File
@@ -0,0 +1,292 @@
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 "../../shared/types";
import "../capabilities";
import { register } from "./registry";
import { runFlow } from "./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: [],
implemented: true
};
}
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("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);
});
});
+36 -26
View File
@@ -137,12 +137,8 @@ export async function runFlow(
options.emit({ type: "run.started", startedAt });
const order = topLevelOrder(flow);
try {
for (const node of order) {
if (options.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
await executeNode(node, ctx, states);
}
await executeTopLevelGraph(flow, ctx, states);
} catch (error) {
if (error instanceof StopSignal) {
options.log("流程被「退出流程」节点终止", "warn");
@@ -179,33 +175,47 @@ export async function runFlow(
};
}
function topLevelOrder(flow: FlowDefinition): FlowNode[] {
async function executeTopLevelGraph(flow: FlowDefinition, ctx: RunContext, states: Map<string, NodeState>): Promise<void> {
const top = flow.nodes.filter((node) => !node.parentId);
if (top.length <= 1) return top;
const topIds = new Set(top.map((node) => node.id));
const incoming = new Map<string, number>();
for (const edge of flow.edges) {
if (topIds.has(edge.source) && topIds.has(edge.target)) {
incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1);
if (!top.length) return;
if (!flow.edges.length) {
for (const node of [...top].sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x)) {
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
await executeNode(node, ctx, states);
}
return;
}
const topIds = new Set(top.map((node) => node.id));
const topById = new Map(top.map((node) => [node.id, node]));
const edges = flow.edges.filter((edge) => topIds.has(edge.source) && topIds.has(edge.target));
const incoming = new Set(edges.map((edge) => edge.target));
const start =
top.find((node) => !incoming.get(node.id) && node.componentId.startsWith("Trigger.")) ||
top.find((node) => !incoming.get(node.id)) ||
top.find((node) => !incoming.has(node.id) && node.componentId.startsWith("Trigger.")) ||
top.find((node) => !incoming.has(node.id)) ||
top[0];
const ordered: FlowNode[] = [];
const visited = new Set<string>();
let current: FlowNode | undefined = start;
while (current && !visited.has(current.id)) {
ordered.push(current);
visited.add(current.id);
const edge = flow.edges.find((item) => item.source === current!.id && topIds.has(item.target));
current = edge ? top.find((node) => node.id === edge.target) : undefined;
}
for (const node of [...top].sort((a, b) => a.position.y - b.position.y)) {
if (!visited.has(node.id)) ordered.push(node);
}
return ordered;
const walk = async (node: FlowNode): Promise<void> => {
if (visited.has(node.id)) return;
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
visited.add(node.id);
try {
await executeNode(node, ctx, states);
} catch (error) {
const errorEdge = edges.find((edge) => edge.source === node.id && edge.sourceHandle === "error");
const target = errorEdge ? topById.get(errorEdge.target) : undefined;
if (!target) throw error;
ctx.vars.error = asRuntimeError(error, node, node.componentId);
ctx.log(`节点失败,转入异常分支:${target.label || target.componentId}`, "warn", node.id);
await walk(target);
return;
}
const normalEdge = edges.find((edge) => edge.source === node.id && edge.sourceHandle !== "error");
const target = normalEdge ? topById.get(normalEdge.target) : undefined;
if (target) await walk(target);
};
await walk(start);
}
async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string, NodeState>): Promise<unknown> {
+1
View File
@@ -8,6 +8,7 @@ export interface BrowserLike {
exists(selector: unknown, timeoutMs?: number): Promise<boolean>;
select(selector: unknown, value: string): Promise<unknown>;
runJs(script: string): Promise<unknown>;
download(selector: unknown, folder: string, filename?: string, timeoutMs?: number): Promise<string>;
screenshot(path: string): Promise<string>;
currentUrl(): Promise<string>;
close(): Promise<void>;
+6 -1
View File
@@ -24,6 +24,8 @@ export function createFlow(name = "未命名流程"): FlowDefinition {
id,
name,
description: "",
enabled: true,
trigger: { type: "manual", label: "手动运行" },
variables: [],
nodes: [
{
@@ -51,7 +53,10 @@ export function listFlows(): FlowSummary[] {
id: flow.id,
name: flow.name || "未命名流程",
updatedAt: flow.meta?.updatedAt || new Date(0).toISOString(),
nodeCount: flow.nodes.length
nodeCount: flow.nodes.length,
enabled: flow.enabled !== false,
triggerLabel: flow.trigger?.label || "手动运行",
lastRunStatus: "never"
});
} catch {
// 忽略损坏文件,避免一个坏流程阻塞列表