refactor(rpa-core): 测试文件迁至 test/ 目录(对面 workspace 规范:产品源码树禁含测试);修 platform-groups 测试预载顺序耦合
This commit is contained in:
1 parent
62652b4fca
commit
4f1fbc07dc
44 files changed
+196
-187
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.1 KiB |
@@ -1,85 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ powershell: vi.fn(), execFile: vi.fn() }));
|
||||
vi.mock("../driver/windows/powershell", () => ({ powershell: mocks.powershell, psString: (value: unknown) => `'${String(value ?? "").replace(/'/g, "''")}'` }));
|
||||
vi.mock("node:child_process", () => ({ execFile: mocks.execFile }));
|
||||
import "./data";
|
||||
import "./control";
|
||||
import "./system-extended";
|
||||
import "./windows-system";
|
||||
|
||||
function run(id: string, args: Record<string, unknown> = {}, signal = new AbortController().signal) {
|
||||
const node: FlowNode = { id: "regression", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
|
||||
return getHandler(id)!({ signal, log: vi.fn(), vars: {} } as unknown as RunContext, node, args, {} as CatalogComponent);
|
||||
}
|
||||
|
||||
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); });
|
||||
|
||||
describe.skipIf(process.platform !== "win32")("应用与系统组件缺陷回归", () => {
|
||||
it("没有匹配进程时返回 [] / null,忽略空行和非法 PID", async () => {
|
||||
mocks.powershell.mockResolvedValue("");
|
||||
await expect(run("App_GetPids", { pattern: "missing.exe" })).resolves.toEqual([]);
|
||||
await expect(run("App_GetPid", { pattern: "missing.exe" })).resolves.toBeNull();
|
||||
mocks.powershell.mockResolvedValue("\r\n0\r\n123\r\nabc\r\n456\r\n");
|
||||
await expect(run("App_GetPids", { pattern: "fixture.exe" })).resolves.toEqual([123, 456]);
|
||||
});
|
||||
|
||||
it("毫秒等待读取 mileSecond,计时和取消均正确", async () => {
|
||||
vi.useFakeTimers();
|
||||
const waiting = Promise.resolve(run("Basic_Delay", { mileSecond: "250" }));
|
||||
let done = false;
|
||||
void waiting.then(() => { done = true; });
|
||||
await vi.advanceTimersByTimeAsync(249);
|
||||
expect(done).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(done).toBe(true);
|
||||
const controller = new AbortController();
|
||||
const cancelled = Promise.resolve(run("Basic_Delay", { mileSecond: "3000" }, controller.signal));
|
||||
const rejection = expect(cancelled).rejects.toMatchObject({ code: "cancelled" });
|
||||
controller.abort();
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it.each([
|
||||
[42, "number", true], ["42", "number", false], [42, "1", true],
|
||||
[[], "list", true], [{ key: 1 }, "dict", true], [null, "null", true],
|
||||
[false, "bool", true], ["中文", "string", true], [3.5, "int", false]
|
||||
])("按目标类型判断 %j → %s", (val, into_type, expected) => {
|
||||
expect(run("Basic_JudgeDataType", { val, into_type })).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["42.5", "number", 42.5], ["42", "1", 42], ["-3.9", "int", -3],
|
||||
["false", "bool", false], ["true", "bool", true], [42, "string", "42"]
|
||||
])("执行类型转换 %j → %s", (val, into_type, expected) => {
|
||||
expect(run("Basic_DataTypeConversion", { val, into_type })).toBe(expected);
|
||||
});
|
||||
|
||||
it("非法数值或未知目标类型给出定位错误,避免静默返回原值", () => {
|
||||
expect(() => run("Basic_DataTypeConversion", { val: "abc", into_type: "int" })).toThrow();
|
||||
expect(() => run("Basic_DataTypeConversion", { val: 1, into_type: "unknown" })).toThrow();
|
||||
expect(() => run("Basic_JudgeDataType", { val: 1, into_type: "unknown" })).toThrow();
|
||||
});
|
||||
|
||||
it("关闭组件按所选模式执行,非法 PID 不降级为按名称批量关闭", async () => {
|
||||
mocks.powershell.mockResolvedValue("");
|
||||
await expect(run("App_KillV1", { close_type: "id", pid: "invalid", p_name: "fixture.exe" })).rejects.toMatchObject({ code: "validation_error" });
|
||||
expect(mocks.powershell).not.toHaveBeenCalled();
|
||||
await run("App_KillV1", { close_type: "name", pid: 123, p_name: "fixture.exe", kill_all: "0" });
|
||||
expect(mocks.powershell.mock.calls[0][0]).toContain("SessionId");
|
||||
expect(mocks.powershell.mock.calls[0][0]).not.toContain("Stop-Process -Id 123");
|
||||
});
|
||||
|
||||
it("唤醒不声称已通过 Windows 登录解锁", async () => {
|
||||
mocks.execFile.mockImplementation((_file, _args, _opts, callback) => callback(null, { stdout: "", stderr: "" }));
|
||||
await expect(run("ScreenControl_ScreenUnlock")).resolves.toMatchObject({ awakened: true, loginUnlockSupported: false });
|
||||
});
|
||||
|
||||
it("屏保自定义密码不受支持时,在触发桌面操作前明确报错", async () => {
|
||||
await expect(run("System_ScreenSaverShowV1", { set_password: "1", password: "" })).rejects.toMatchObject({ code: "validation_error" });
|
||||
expect(mocks.execFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./data";
|
||||
import "./desktop";
|
||||
import "./system-extended";
|
||||
import "./utility-extended";
|
||||
import "./common-final";
|
||||
import "./control";
|
||||
import "./windows-system";
|
||||
import "./basic-encryption";
|
||||
|
||||
const ctx = {
|
||||
signal: new AbortController().signal,
|
||||
log: () => {},
|
||||
vars: {},
|
||||
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-app", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} } as unknown as FlowNode;
|
||||
|
||||
async function run(componentId: string, args: Record<string, unknown>, ctxOverride?: RunContext): Promise<unknown> {
|
||||
const handler = getHandler(componentId);
|
||||
if (!handler) throw new Error(`组件 ${componentId} 未注册运行时`);
|
||||
const n = { ...node, componentId } as unknown as FlowNode;
|
||||
return handler(ctxOverride ?? ctx, n, args, {} as CatalogComponent);
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform !== "win32")("应用与系统组件冒烟", () => {
|
||||
it("数据类型判断族", async () => {
|
||||
expect(await run("Basic_IsNumber", { val: "12.5" })).toBe(true);
|
||||
expect(await run("Basic_IsNumber", { val: "abc" })).toBe(false);
|
||||
expect(await run("Basic_IsNull", { val: "" })).toBe(true);
|
||||
expect(await run("Basic_IsNull", { val: "x" })).toBe(false);
|
||||
expect(await run("Basic_IsArray", { val: [1, 2] })).toBe(true);
|
||||
expect(await run("Basic_IsDictionary", { val: { a: 1 } })).toBe(true);
|
||||
expect(await run("Basic_Type", { val: [1] })).toBe("list");
|
||||
expect(await run("Basic_Type", { val: "s" })).toBe("string");
|
||||
expect(await run("Basic_JudgeDataType", { val: 5, into_type: "int" })).toBe(true);
|
||||
expect(await run("Basic_JudgeDataType", { val: 5.5, into_type: "int" })).toBe(false);
|
||||
console.log("[数据判断族] 通过");
|
||||
});
|
||||
|
||||
it("数据转换族", async () => {
|
||||
expect(await run("Basic_ConvertString", { val: 123 })).toBe("123");
|
||||
expect(await run("Basic_ConvertInt", { val: "12.9" })).toBe(12);
|
||||
expect(await run("Basic_ConvertBool", { val: 1 })).toBe(true);
|
||||
expect(await run("Basic_ConvertNumber", { val: "3.14" })).toBe(3.14);
|
||||
expect(await run("Basic_DataTypeConversion", { val: "是", into_type: "bool" })).toBe(true);
|
||||
expect(await run("Basic_DataTypeConversion", { val: 8, into_type: "string" })).toBe("8");
|
||||
console.log("[数据转换族] 通过");
|
||||
});
|
||||
|
||||
it("复制/随机/密文", async () => {
|
||||
const src = { a: [1, { b: 2 }] };
|
||||
const cloned = (await run("Basic_Clone", { val: src })) as typeof src;
|
||||
expect(cloned).toEqual(src);
|
||||
expect(cloned).not.toBe(src);
|
||||
const ri = (await run("Basic_RandomInt", { min: 5, max: 5 })) as number;
|
||||
expect(ri).toBe(5);
|
||||
const rnd = (await run("Basic_Rnd", {})) as number;
|
||||
expect(rnd).toBeGreaterThanOrEqual(0);
|
||||
expect(rnd).toBeLessThan(1);
|
||||
expect(await run("Basic_Dodecryption", { words: Buffer.from("hello-世界", "utf8").toString("base64") })).toBe("hello-世界");
|
||||
console.log("[复制/随机/密文] 通过");
|
||||
});
|
||||
|
||||
it("延迟/调试输出/设置变量", async () => {
|
||||
const logs: string[] = [];
|
||||
const ctxLog = { ...ctx, log: (m: string) => logs.push(m), vars: {} } as unknown as RunContext;
|
||||
const t0 = Date.now();
|
||||
expect(await run("Basic_Delay", { mileSecond: 100 })).toBe(0.1);
|
||||
expect(Date.now() - t0).toBeGreaterThanOrEqual(95);
|
||||
await run("Basic_DebugOutput", { msg: "冒烟输出" }, ctxLog);
|
||||
expect(logs.join()).toContain("冒烟输出");
|
||||
const setNode = { ...node, options: { outputVar: "v_test" } } as unknown as FlowNode;
|
||||
const handler = getHandler("Basic_SetVariable_V1")!;
|
||||
expect(await handler(ctxLog, setNode, { value: "42", variableType: "rpa_int" }, {} as CatalogComponent)).toBe(42);
|
||||
expect((ctxLog as unknown as { vars: Record<string, unknown> }).vars.v_test).toBe(42);
|
||||
console.log("[延迟/输出/变量] 通过");
|
||||
});
|
||||
|
||||
it("自定义配置与业务计数", async () => {
|
||||
const cfg = (await run("CustomConfig_GetConfig", {
|
||||
jsonlist: JSON.stringify([{ name: "host", value: "127.0.0.1" }]),
|
||||
configName: "host"
|
||||
})) as string;
|
||||
expect(cfg).toBe("127.0.0.1");
|
||||
const ctxCount = { ...ctx, vars: {} } as unknown as RunContext;
|
||||
expect(await run("Basic_BusinessOrderCount", { start_count: 100, count: 3 }, ctxCount)).toBe(103);
|
||||
expect(await run("Basic_BusinessOrderCount", { start_count: 100, count: 3 }, ctxCount)).toBe(106);
|
||||
console.log("[配置/计数] 通过");
|
||||
});
|
||||
|
||||
it("系统路径与环境变量", async () => {
|
||||
expect(String(await run("System_GetTempPath", {}))).toMatch(/Temp|tmp/i);
|
||||
expect(String(await run("System_GetHomePath", {}))).toMatch(/Users|home/i);
|
||||
expect(String(await run("System_GetSystemPath", { path_name: "desktop" }))).toMatch(/Desktop/);
|
||||
expect(await run("System_GetEnv", { name: "PATH" })).toBeTruthy();
|
||||
expect(await run("System_SetEnv", { name: "RPA_SMOKE", value: "ok" })).toBe("ok");
|
||||
expect(await run("System_GetEnv", { name: "RPA_SMOKE" })).toBe("ok");
|
||||
expect(await run("Basic_GetProcessInfo", {})).toBeTruthy();
|
||||
console.log("[系统路径/环境变量] 通过");
|
||||
});
|
||||
|
||||
it("输入法状态获取", async () => {
|
||||
const st = (await run("System_GetInputMethodState", {})) as { culture: string; layout: string };
|
||||
console.log("[输入法]", JSON.stringify(st));
|
||||
expect(st.culture).toBeTruthy();
|
||||
}, 60000);
|
||||
|
||||
it("执行命令行两种组件", async () => {
|
||||
const out1 = String(await run("System_ExecuteCommand", { command: "echo rpa-smoke-123" }));
|
||||
expect(out1).toContain("rpa-smoke-123");
|
||||
const out2 = String(await run("System_ExecuteCommandV1", { command_type: "1", dos_command: "echo dos-ok-456" }));
|
||||
expect(out2).toContain("dos-ok-456");
|
||||
const out3 = String(await run("System_ExecuteCommandV1", { command_type: "3", ps_command: "Write-Output 'ps-ok-789'" }));
|
||||
expect(out3).toContain("ps-ok-789");
|
||||
console.log("[执行命令] 通过");
|
||||
}, 120000);
|
||||
|
||||
it("应用生命周期: 启动→查PID→关闭", async () => {
|
||||
const pid = (await run("App_Run", { executeable: "notepad.exe" })) as number;
|
||||
expect(Number.isInteger(pid)).toBe(true);
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
// Win11 notepad.exe 为应用别名:Start-Process 返回的启动器 PID 会立即退出,真实进程另获新 PID,
|
||||
// 因此不比较具体 PID,只验证「能查到运行中的 notepad 进程」
|
||||
const single = (await run("App_GetPid", { pattern: "notepad" })) as number | null;
|
||||
expect(single).toBeTruthy();
|
||||
const all = (await run("App_GetPids", { p_name: "notepad" })) as number[];
|
||||
expect(all.length).toBeGreaterThan(0);
|
||||
const killed = await run("App_KillV1", { close_type: "name", p_name: "notepad" });
|
||||
console.log("[应用生命周期] 启动PID:", pid, "查询到:", all, "关闭:", JSON.stringify(killed));
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const after = (await run("App_GetPids", { pattern: "notepad" })) as number[];
|
||||
expect(after.length).toBe(0);
|
||||
}, 120000);
|
||||
|
||||
it("Windows凭据: 添加→读取→删除", async () => {
|
||||
const target = "RPA_SMOKETEST_GENERIC";
|
||||
expect((await run("System_AddCredential", { application: target, name: "smoke-user", password: "p@ss" })) as { added: boolean }).toEqual({ added: true, target });
|
||||
const read = (await run("System_ReadCredential", { application: target })) as { exists: boolean; detail: string };
|
||||
expect(read.exists).toBe(true);
|
||||
expect(read.detail).toContain("smoke-user");
|
||||
expect((await run("System_DeleteCredential", { application: target })) as { deleted: boolean }).toEqual({ deleted: true, target });
|
||||
const readAfter = (await run("System_ReadCredential", { application: target })) as { exists: boolean };
|
||||
expect(readAfter.exists).toBe(false);
|
||||
console.log("[凭据闭环] 通过");
|
||||
}, 120000);
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./desktop";
|
||||
import "./basic-encryption";
|
||||
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, id).toBeTruthy();
|
||||
return handler!(context, node(id), args, component);
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform !== "win32")("manual keyboard and encrypted variables", () => {
|
||||
it("registers manual keyboard switches", async () => {
|
||||
await expect(run("Keyboard_EnableManualOperation", { random_move: true, random_click: true, random_sleep: false, min_time: 0, max_time: 0 })).resolves.toMatchObject({ enabled: true });
|
||||
await expect(run("Keyboard_DisableManualOperation", {})).resolves.toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("decodes base64 and SM4 ciphertext", async () => {
|
||||
await expect(run("Basic_Dodecryption", { words: Buffer.from("hello", "utf8").toString("base64") })).resolves.toBe("hello");
|
||||
const { sm4 } = await import("sm-crypto");
|
||||
const key = Buffer.from("INPUT_PARAM_KEY4", "utf8").toString("hex");
|
||||
const cipher = sm4.encrypt("secret", key, { padding: "pkcs#7" });
|
||||
await expect(run("Basic_Dodecryption", { words: cipher })).resolves.toBe("secret");
|
||||
await expect(run("Basic_Dodecryption", {})).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
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 "../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");
|
||||
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output '中文输出测试'" }, component)).resolves.toBe("中文输出测试");
|
||||
});
|
||||
|
||||
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");
|
||||
const union = getHandler("Mail_UnionSend_v1");
|
||||
expect(union).toBeTruthy();
|
||||
await union!(context, node("Mail_UnionSend_v1"), {
|
||||
account_obj: { host: "127.0.0.1", port, account: "robot@example.com" },
|
||||
receiver: "owner@example.com", subject: "Union smoke", content: "union delivered"
|
||||
}, component);
|
||||
expect(received).toContain("Union smoke");
|
||||
expect(received).toContain("union 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);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./remaining-core";
|
||||
import "./captcha-caps"; // last-wins:覆盖 remaining-core 的通用实现
|
||||
import { cleanCodeText, parseCalculation } from "./captcha-caps";
|
||||
|
||||
// 测试图随仓库走(__fixtures__/captcha,PIL 生成),真实 OCR 模型来自 npm 依赖
|
||||
const fixturesDir = path.join(import.meta.dirname, "__fixtures__", "captcha");
|
||||
const fixture = (name: string) => path.join(fixturesDir, `${name}.png`);
|
||||
const ctx = { signal: new AbortController().signal, log: () => {}, vars: {}, evidenceDir: fixturesDir } as unknown as RunContext;
|
||||
const node: FlowNode = { id: "smoke", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} };
|
||||
|
||||
describe("parseCalculation 解析层(纯逻辑)", () => {
|
||||
it("基础四则与等号问号", () => {
|
||||
expect(parseCalculation("3+5=?")).toEqual({ expression: "3+5", result: 8 });
|
||||
});
|
||||
it("x/X/* 归一为乘", () => {
|
||||
expect(parseCalculation("6x4=?")!.result).toBe(24);
|
||||
expect(parseCalculation("9X9=?")!.result).toBe(81);
|
||||
});
|
||||
it("全角与杂字符", () => {
|
||||
expect(parseCalculation("12+7=?")!.result).toBe(19);
|
||||
});
|
||||
it("长破折号/Unicode 减号", () => {
|
||||
expect(parseCalculation("12—7")!.result).toBe(5);
|
||||
});
|
||||
it("除法整除与小数", () => {
|
||||
expect(parseCalculation("8/2")!.result).toBe(4);
|
||||
expect(parseCalculation("7/2")!.result).toBe(3.5);
|
||||
});
|
||||
it("无算式返回 null", () => {
|
||||
expect(parseCalculation("ab12cd")).toBeNull();
|
||||
});
|
||||
it("字符验证码清洗", () => {
|
||||
expect(cleanCodeText("a b 7 X ? = ")).toBe("ab7X");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(fixture("captcha_0_3p5_q")))("OCR_CalculationCode 端到端(真实模型)", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["captcha_0_3p5_q", "8"],
|
||||
["captcha_1_12m7_q", "5"],
|
||||
["captcha_2_6t4_q", "24"],
|
||||
["captcha_3_15p28_q", "43"],
|
||||
["captcha_4_9t9_q", "81"],
|
||||
];
|
||||
for (const [file, expected] of cases) {
|
||||
it(`${file} -> ${expected}`, async () => {
|
||||
const handler = getHandler("OCR_CalculationCode");
|
||||
expect(handler).toBeTruthy();
|
||||
const out = await handler!(ctx, node, { path: fixture(file) }, {} as CatalogComponent);
|
||||
console.log(`[${file}]`, String(out));
|
||||
expect(String(out)).toBe(expected);
|
||||
}, 300000);
|
||||
}
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./remaining-core";
|
||||
|
||||
const ctx = { signal: new AbortController().signal, log: () => {}, vars: {}, evidenceDir: "D:/office-test2/ocr-test" } as unknown as RunContext;
|
||||
const node: FlowNode = { id: "smoke", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} };
|
||||
|
||||
describe("算术验证码现状侦察", () => {
|
||||
const files = ["captcha_0_3p5_q", "captcha_1_12m7_q", "captcha_2_6t4_q", "captcha_3_15p28_q", "captcha_4_9t9_q"];
|
||||
it("当前 OCR 原始输出", async () => {
|
||||
for (const f of files) {
|
||||
const handler = getHandler("OCR_CalculationCode");
|
||||
const out = await handler!(ctx, node, { path: `D:/office-test2/ocr-test/${f}.png` }, {} as CatalogComponent);
|
||||
console.log(`[${f}]`, JSON.stringify(out));
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
}, 600000);
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./index";
|
||||
|
||||
const dirs: string[] = []; const context = { vars: {} } as RunContext; const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||
afterEach(async () => { context.vars = {}; await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); });
|
||||
|
||||
describe("final common capabilities", () => {
|
||||
it("registers window, keyboard, clipboard, system, PDF and webhook components", () => {
|
||||
for (const id of ["Window_ActiveWindow", "Window_CloseWindow_v1", "Window_GetScreenSize", "Window_WindowList", "Window_GetRect", "Window_MaximumSize", "Window_SetWindowShow", "Window_WaitWindow", "Keyboard_DriveSend", "Keyboard_DriveSend_V1", "Keyboard_DriveSend_V2", "Clipboard_SetImage", "Clipboard_SetImageV1", "Clipboard_SaveImage", "Clipboard_SaveImageV1", "System_PlaySound", "CustomConfig_GetConfig", "Basic_BusinessOrderCount", "PDF_ConcatPdfV1", "PDF_SetPdfWaterMark", "PDF_SetPdfWaterMarkV1", "MessageNotification_DingTalk_Message", "MessageNotification_Feishu_Message", "MessageNotification_WXWork_Message"]) expect(getHandler(id), id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reads custom config and increments business counters", async () => {
|
||||
await expect(run("CustomConfig_GetConfig", { jsonlist: [{ name: "timeout", value: 30 }], configName: "timeout" })).resolves.toBe(30);
|
||||
await expect(run("Basic_BusinessOrderCount", { start_count: 10, count: 2 })).resolves.toBe(12);
|
||||
await expect(run("Basic_BusinessOrderCount", { start_count: 10, count: 2 })).resolves.toBe(14);
|
||||
});
|
||||
|
||||
it("adds an ASCII PDF watermark", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-common-final-")); dirs.push(directory); const input = path.join(directory, "input.pdf"); const pdf = await PDFDocument.create(); pdf.addPage([300, 300]); await writeFile(input, await pdf.save());
|
||||
const output = await run("PDF_SetPdfWaterMark", { fileInPath: input, markTextInfo: "RPA", newPdfName: "watermark.pdf" }) as string;
|
||||
expect((await readFile(output)).byteLength).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("posts the three webhook formats to a local server", async () => {
|
||||
const bodies: unknown[] = []; const server = createServer((request, response) => { let raw = ""; request.on("data", (chunk) => raw += chunk); request.on("end", () => { bodies.push(JSON.parse(raw)); response.writeHead(200, { "Content-Type": "application/json" }); response.end('{"ok":true}'); }); });
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); const webhook = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}/hook`;
|
||||
try { await run("MessageNotification_DingTalk_Message", { webhook, content_text: "ding" }); await run("MessageNotification_Feishu_Message", { webhook, content_text: "fei" }); await run("MessageNotification_WXWork_Message", { webhook, content_text: "wx" }); expect(bodies).toHaveLength(3); } finally { await new Promise<void>((resolve) => server.close(() => resolve())); }
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { capabilityIds } from "../engine/registry";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import "./index"; // 汇总导入全部能力模块(与运行时同构)
|
||||
|
||||
interface CatalogComp {
|
||||
id: string;
|
||||
name?: string;
|
||||
fields?: { key: string; label?: string; widget?: string; options?: { label: string; value: string }[]; defaultValue?: unknown }[];
|
||||
}
|
||||
|
||||
describe("系统组件兼容性全量审查", () => {
|
||||
it("注册覆盖 + 面板可见性 + 字段契约核对", async () => {
|
||||
const catalog: CatalogComp[] = JSON.parse(readFileSync("resources/catalog.json", "utf-8")).components ?? [];
|
||||
const dup = JSON.parse(readFileSync("resources/capability-duplicates.json", "utf-8"));
|
||||
const dupMap = new Map<string, { status: string; canonical?: string }>(
|
||||
(Array.isArray(dup) ? dup : []).map((d: { id: string; status: string; canonical?: string }) => [d.id, { status: d.status, canonical: d.canonical }])
|
||||
);
|
||||
const registered = new Set(capabilityIds());
|
||||
|
||||
// 1) catalog 组件 → 运行时注册
|
||||
const unregistered: { id: string; name?: string }[] = [];
|
||||
for (const c of catalog) {
|
||||
if (!registered.has(c.id)) unregistered.push({ id: c.id, name: c.name });
|
||||
}
|
||||
|
||||
// 2) 幽灵注册:handler 有但 catalog 没有(可能是内部别名,不一定是问题,但要列出来)
|
||||
const catalogIds = new Set(catalog.map((c) => c.id));
|
||||
const ghosts: string[] = [];
|
||||
for (const id of registered) {
|
||||
if (!catalogIds.has(id)) ghosts.push(id);
|
||||
}
|
||||
|
||||
// 3) duplicates 一致性:hide 必须有 canonical 且 canonical 已注册;keep/隐藏组件的执行器必须在
|
||||
const dupIssues: string[] = [];
|
||||
for (const [id, info] of dupMap) {
|
||||
if (info.status === "hide" && info.canonical && !registered.has(info.canonical)) {
|
||||
dupIssues.push(`hide 项 ${id} 的 canonical ${info.canonical} 未注册`);
|
||||
}
|
||||
if (info.status === "keep" && !registered.has(id)) {
|
||||
dupIssues.push(`keep 项 ${id} 未注册(面板可见但运行会报未实现)`);
|
||||
}
|
||||
if (info.status === "hide" && !info.canonical && !registered.has(id)) {
|
||||
dupIssues.push(`hide 项 ${id} 无 canonical 且未注册(旧流程节点将无法运行)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 字段契约抽查:select 枚举 defaultValue 必须在 options 里;hidden 字段不能 required
|
||||
const fieldIssues: string[] = [];
|
||||
for (const c of catalog) {
|
||||
for (const f of c.fields ?? []) {
|
||||
if (f.widget === "select" && Array.isArray(f.options) && f.options.length > 0) {
|
||||
const values = f.options.map((o) => String(o.value));
|
||||
if (f.defaultValue !== undefined && f.defaultValue !== "" && !values.includes(String(f.defaultValue))) {
|
||||
fieldIssues.push(`${c.id}.${f.key} select 默认值 "${f.defaultValue}" 不在选项值 [${values.join(",")}] 中`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
catalogTotal: catalog.length,
|
||||
registeredTotal: registered.size,
|
||||
unregisteredCount: unregistered.length,
|
||||
unregistered: unregistered.slice(0, 50),
|
||||
ghostCount: ghosts.length,
|
||||
ghosts: ghosts.slice(0, 40),
|
||||
dupIssues,
|
||||
fieldIssues: fieldIssues.slice(0, 40),
|
||||
fieldIssueCount: fieldIssues.length
|
||||
};
|
||||
writeFileSync(".compat-report.txt", JSON.stringify(report, null, 1), "utf8");
|
||||
}, 120000);
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"ok":true}'));
|
||||
const detectMock = vi.hoisted(() => vi.fn(async () => ({
|
||||
texts: [
|
||||
{ text: "确认保存", box: [[0, 0], [20, 0], [20, 10], [0, 10]] },
|
||||
{ text: "确认", box: [[100, 200], [130, 200], [130, 210], [100, 210]] }
|
||||
]
|
||||
})));
|
||||
// 屏幕服务模拟: 默认无画面(走 GDI 回退), 多屏用例按显示器 id/请求宽度提供对应画面
|
||||
const electronState = vi.hoisted(() => ({ sources: [] as Array<{ display_id: string; thumbnail: any }> }));
|
||||
|
||||
vi.mock("../driver/windows/powershell", () => ({ powershell: powershellMock }));
|
||||
vi.mock("@gutenye/ocr-node", () => ({
|
||||
default: { create: vi.fn(async () => ({ detect: detectMock })) }
|
||||
}));
|
||||
|
||||
import { getHandler } from "../engine/registry";
|
||||
import { setHostBridge, type HostDisplay } from "../engine/host-bridge";
|
||||
import "./cv-vision";
|
||||
|
||||
const displayOf = (id: string, x: number, width: number, height: number, scaleFactor: number): HostDisplay => ({
|
||||
id,
|
||||
bounds: { x, y: 0, width, height },
|
||||
scaleFactor
|
||||
});
|
||||
const displays: HostDisplay[] = [
|
||||
displayOf("1", 0, 1920, 1080, 1),
|
||||
displayOf("2", 1920, 1280, 720, 2)
|
||||
];
|
||||
|
||||
setHostBridge({
|
||||
createFormWindow: () => { throw new Error("not used in cv-vision tests"); },
|
||||
dipToScreenPoint: (point) => ({ x: point.x, y: point.y }),
|
||||
screenToDipPoint: (point) => ({ x: point.x, y: point.y }),
|
||||
loadAiConfig: () => ({ baseUrl: "", apiKey: "", model: "", visionModel: "" }),
|
||||
screen: {
|
||||
listDisplays: () => displays,
|
||||
primaryDisplay: () => displays[0],
|
||||
dipToScreenPoint: (point) => ({ x: point.x, y: point.y }),
|
||||
displayMatching: () => displays[0],
|
||||
captureDisplay: async (displayId: string, wantWidth: number, wantHeight: number) => {
|
||||
// 模拟真实 captureDisplay 的对位规则,但不做 sources[0] 兜底:
|
||||
// 保持"主屏无画面 → 跳过该屏"的旧多屏语义
|
||||
const source = electronState.sources.find((item) => item.display_id === displayId && item.thumbnail.getSize().width === wantWidth)
|
||||
|| electronState.sources.find((item) => item.thumbnail.getSize().width === wantWidth);
|
||||
if (!source || source.thumbnail.isEmpty()) throw new Error(`无法获取显示器 ${displayId} 的屏幕截图`);
|
||||
void wantHeight;
|
||||
return source.thumbnail.toPNG();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("CV vision capabilities", () => {
|
||||
beforeEach(() => {
|
||||
powershellMock.mockClear();
|
||||
detectMock.mockClear();
|
||||
electronState.sources = [];
|
||||
});
|
||||
|
||||
it.each(["CV_GetText", "CV_SetText", "CV_MouseHover", "CV_MouseClick"])("registers %s", (id) => {
|
||||
expect(getHandler(id), id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects an empty CV selector", async () => {
|
||||
await expect(getHandler("CV_GetText")!({ log: vi.fn() } as any, { id: "n1", componentId: "CV_GetText" } as any, {}, {} as any))
|
||||
.rejects.toThrow("CV 目标为空");
|
||||
});
|
||||
|
||||
it("locates OCR text and clicks its center with offset", async () => {
|
||||
const handler = getHandler("CV_MouseClick")!;
|
||||
await expect(handler({ log: vi.fn() } as any, { id: "n1", componentId: "CV_MouseClick" } as any, {
|
||||
cvSelector: "确认",
|
||||
offsetX: 5,
|
||||
offsetY: 2
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
expect(detectMock).toHaveBeenCalledTimes(1);
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[1]?.[0] ?? "");
|
||||
expect(script).toContain("SetCursorPos(15, 7)");
|
||||
expect(script).toContain("mouse_event(0x0002");
|
||||
});
|
||||
|
||||
it("returns matched OCR text", async () => {
|
||||
await expect(getHandler("CV_GetText")!({ log: vi.fn() } as any, { id: "n2", componentId: "CV_GetText" } as any, {
|
||||
cvSelector: "保存"
|
||||
}, {} as any)).resolves.toBe("确认保存");
|
||||
expect(powershellMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restricts matching to the search region when provided", async () => {
|
||||
// 屏幕上有两处「确认」: 区域(0,0,50,50)内只应命中 (5,5) 那处, 而不是第一个全屏命中 (115,205)
|
||||
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n3", componentId: "CV_MouseClick" } as any, {
|
||||
cvSelector: "确认",
|
||||
searchRegion: JSON.stringify({ x: 0, y: 0, width: 50, height: 50 })
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[1]?.[0] ?? "");
|
||||
expect(script).toContain("SetCursorPos(10, 5)");
|
||||
});
|
||||
|
||||
it("rejects when target is outside the search region", async () => {
|
||||
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n4", componentId: "CV_MouseClick" } as any, {
|
||||
cvSelector: "确认",
|
||||
searchRegion: JSON.stringify({ x: 500, y: 500, width: 50, height: 50 })
|
||||
}, {} as any)).rejects.toThrow("未找到目标文本");
|
||||
});
|
||||
|
||||
it("locates targets on a secondary display and maps DIP coordinates", async () => {
|
||||
// 主屏无画面, 副屏(bounds 1920,0 1280x720 缩放2)物理中心(450,210) → DIP(2145,105)
|
||||
const fakeThumb = (w: number, h: number): any => ({
|
||||
isEmpty: () => false,
|
||||
getSize: () => ({ width: w, height: h }),
|
||||
toPNG: () => Buffer.from([137, 80, 78, 71])
|
||||
});
|
||||
electronState.sources = [{ display_id: "2", thumbnail: fakeThumb(2560, 1440) }];
|
||||
detectMock.mockImplementationOnce(async () => ({
|
||||
texts: [{ text: "副屏按钮", box: [[400, 200], [500, 200], [500, 220], [400, 220]] }]
|
||||
}));
|
||||
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n5", componentId: "CV_MouseClick" } as any, {
|
||||
cvSelector: "副屏按钮"
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
|
||||
expect(script).toContain("SetCursorPos(2145, 105)");
|
||||
});
|
||||
|
||||
it("maps region-image hits through the display scale", async () => {
|
||||
// 框选图来自缩放2的副屏: 图内物理中心(120,90) + 框选DIP原点(1920,0) → DIP(1980,45)
|
||||
const regionShot = path.join(os.tmpdir(), "rpa-cv-region-test-" + Date.now() + ".png");
|
||||
fs.writeFileSync(regionShot, Buffer.from([137, 80, 78, 71]));
|
||||
try {
|
||||
detectMock.mockImplementationOnce(async () => ({
|
||||
texts: [{ text: "确认", box: [[100, 80], [140, 80], [140, 100], [100, 100]] }]
|
||||
}));
|
||||
await expect(getHandler("CV_MouseHover")!({ log: vi.fn() } as any, { id: "n6", componentId: "CV_MouseHover" } as any, {
|
||||
cvSelector: "确认",
|
||||
searchRegion: JSON.stringify({ x: 1920, y: 0, width: 640, height: 360, scale: 2, imagePath: regionShot })
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
|
||||
expect(script).toContain("SetCursorPos(1980, 45)");
|
||||
} finally {
|
||||
try { fs.unlinkSync(regionShot); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Catalog, CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./data-extended";
|
||||
|
||||
const ids = [
|
||||
"Collection_Create", "Collection_HasItem", "Collection_Count", "Collection_Remove", "Collection_Add",
|
||||
"Ini_Read", "Ini_Write", "Ini_ListSection", "Ini_ListKey", "Ini_DeleteKey", "Ini_DeleteSection",
|
||||
"Log_Info", "Log_Error", "ExceptionUtil_Throws",
|
||||
"Bivartable_CreateDf", "Bivartable_CreateDfV1", "Bivartable_DoCreate",
|
||||
"Bivartable_GetMaxValue", "Bivartable_GetMinValue", "Bivartable_GetSumValue", "Bivartable_GetAvgValue",
|
||||
"Bivartable_SummaryCalculationDf", "Bivartable_DeleteDuplicateRow", "Bivartable_MergeDf",
|
||||
"Bivartable_DoMerge", "Bivartable_FilterDf", "Bivartable_DoFilter"
|
||||
] as const;
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const log = vi.fn();
|
||||
const context = { log } as unknown as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (componentId: string): FlowNode => ({
|
||||
id: `data_${componentId}`,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {}
|
||||
});
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-data-extended-"));
|
||||
temporaryDirectories.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 () => {
|
||||
log.mockReset();
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("extended local data 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(15);
|
||||
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("creates and mutates unique local collections", async () => {
|
||||
const collection = await run("Collection_Create") as unknown[];
|
||||
await run("Collection_Add", { collection, value: "A" });
|
||||
await run("Collection_Add", { collection, value: "A" });
|
||||
await run("Collection_Add", { collection, value: "B" });
|
||||
await expect(run("Collection_HasItem", { collection, item: "A" })).resolves.toBe(true);
|
||||
await expect(run("Collection_Count", { collection })).resolves.toBe(2);
|
||||
await run("Collection_Remove", { collection, value: "A" });
|
||||
expect(collection).toEqual(["B"]);
|
||||
});
|
||||
|
||||
it("persists, lists, reads, and deletes INI values", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const file = path.join(directory, "nested", "settings.ini");
|
||||
await run("Ini_Write", { file, section: "App", key: "name", value: "RPA Studio" });
|
||||
await run("Ini_Write", { file, section: "App", key: "workers", value: 3 });
|
||||
await run("Ini_Write", { file, section: "Network", key: "timeout", value: 30 });
|
||||
await expect(run("Ini_Read", { file, section: "App", key: "name" })).resolves.toBe("RPA Studio");
|
||||
await expect(run("Ini_Read", { file, section: "Missing", key: "key", defaultValue: "fallback" })).resolves.toBe("fallback");
|
||||
await expect(run("Ini_ListSection", { file })).resolves.toEqual(["App", "Network"]);
|
||||
await expect(run("Ini_ListKey", { file, section: "App" })).resolves.toEqual(["name", "workers"]);
|
||||
await run("Ini_DeleteKey", { file, section: "App", key: "workers" });
|
||||
await run("Ini_DeleteSection", { file, section: "Network" });
|
||||
await expect(run("Ini_ListKey", { file, section: "App" })).resolves.toEqual(["name"]);
|
||||
await expect(run("Ini_ListSection", { file })).resolves.toEqual(["App"]);
|
||||
await expect(readFile(file, "utf8")).resolves.toContain("name=RPA Studio");
|
||||
});
|
||||
|
||||
it("writes runtime logs and raises a located user exception", async () => {
|
||||
await run("Log_Info", { msg: "started" });
|
||||
await run("Log_Error", { msg: "failed" });
|
||||
expect(log).toHaveBeenNthCalledWith(1, "started", "info", "data_Log_Info");
|
||||
expect(log).toHaveBeenNthCalledWith(2, "failed", "error", "data_Log_Error");
|
||||
await expect(run("ExceptionUtil_Throws", { msg: "stop now" })).rejects.toMatchObject({
|
||||
name: "RuntimeError", code: "user_exception", message: "stop now", nodeId: "data_ExceptionUtil_Throws"
|
||||
});
|
||||
});
|
||||
|
||||
it("creates tables from arrays, records, and column dictionaries", async () => {
|
||||
await expect(run("Bivartable_CreateDf", { data: [[1, 2], [3, 4]], columnList: ["A", "B"] }))
|
||||
.resolves.toEqual([["A", "B"], [1, 2], [3, 4]]);
|
||||
await expect(run("Bivartable_CreateDfV1", { data: [{ name: "A", score: 2 }, { name: "B", score: 4 }] }))
|
||||
.resolves.toEqual([["name", "score"], ["A", 2], ["B", 4]]);
|
||||
await expect(run("Bivartable_DoCreate", { data: { name: ["A", "B"], score: [2, 4] } }))
|
||||
.resolves.toEqual([["name", "score"], ["A", 2], ["B", 4]]);
|
||||
});
|
||||
|
||||
it("calculates scalar and grouped table aggregates", async () => {
|
||||
const input = [["team", "score"], ["A", 2], ["A", 4], ["B", 3]];
|
||||
await expect(run("Bivartable_GetMaxValue", { df: input, maxvaluecols: "score" })).resolves.toBe(4);
|
||||
await expect(run("Bivartable_GetMinValue", { df: input, minvaluecols: "score" })).resolves.toBe(2);
|
||||
await expect(run("Bivartable_GetSumValue", { df: input, sumvaluecols: "score" })).resolves.toBe(9);
|
||||
await expect(run("Bivartable_GetAvgValue", { df: input, avgvaluecols: "score", groupby: "team" }))
|
||||
.resolves.toEqual([["team", "score"], ["A", 3], ["B", 3]]);
|
||||
await expect(run("Bivartable_SummaryCalculationDf", { df: input, name: "score", method: "sum" })).resolves.toBe(9);
|
||||
});
|
||||
|
||||
it("deduplicates, merges, and filters local tables", async () => {
|
||||
const duplicated = [["id", "name"], [1, "A"], [1, "A2"], [2, "B"]];
|
||||
await expect(run("Bivartable_DeleteDuplicateRow", { df: duplicated, change_type: "name", col_name: ["id"], mode: "first" }))
|
||||
.resolves.toEqual([["id", "name"], [1, "A"], [2, "B"]]);
|
||||
const left = [["id", "name"], [1, "A"], [2, "B"]];
|
||||
const right = [["id", "score"], [1, 90], [3, 70]];
|
||||
await expect(run("Bivartable_MergeDf", { df1: left, df2: right, on_name: "id", merge_type: "left" }))
|
||||
.resolves.toEqual([["id", "name", "score"], [1, "A", 90], [2, "B", null]]);
|
||||
await expect(run("Bivartable_DoMerge", { df1: left, df2: right, onx: "id", howx: "inner" }))
|
||||
.resolves.toEqual([["id", "name", "score"], [1, "A", 90]]);
|
||||
await expect(run("Bivartable_FilterDf", { df: left, col_name: "id", choice: ">", criteria: 1 }))
|
||||
.resolves.toEqual([["id", "name"], [2, "B"]]);
|
||||
await expect(run("Bivartable_DoFilter", { df: [["id", "score"], [1, 70], [2, 90], [3, 95]], filter: "(df['id'] > 1) & (df['score'] >= 90)" }))
|
||||
.resolves.toEqual([["id", "score"], [2, 90], [3, 95]]);
|
||||
});
|
||||
});
|
||||
@@ -1,283 +0,0 @@
|
||||
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<string, unknown>,
|
||||
evidenceDir: "D:/office-test2",
|
||||
runId: "smoke-run",
|
||||
flow: { id: "smoke-flow", name: "冒烟", description: "", enabled: true, trigger: null, meta: { appVersion: "dev", createdAt: "", updatedAt: "" }, nodes: [] }
|
||||
} as unknown as RunContext;
|
||||
|
||||
const node = { id: "smoke-data", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} } as unknown as FlowNode;
|
||||
|
||||
async function run(componentId: string, args: Record<string, unknown>, context?: RunContext): Promise<unknown> {
|
||||
const handler = getHandler(componentId);
|
||||
if (!handler) throw new Error(`组件 ${componentId} 未注册运行时`);
|
||||
return handler(context ?? ctx, { ...node, componentId } as unknown as FlowNode, args, {} as CatalogComponent);
|
||||
}
|
||||
|
||||
const sandbox = mkdtempSync(path.join(tmpdir(), "rpa-data-smoke-"));
|
||||
afterAll(() => { rmSync(sandbox, { recursive: true, force: true }); });
|
||||
|
||||
describe("数据处理/文件/编排 冒烟", () => {
|
||||
it("字符串族 StrUtil", async () => {
|
||||
expect(await run("StrUtil_Len", { string: "你好world" })).toBe(7);
|
||||
expect(await run("StrUtil_InStr", { string: "hello", search: "ll" })).toBe(2);
|
||||
expect(await run("StrUtil_Replace", { string: "a-b-c", search: "-", replace: "+" })).toBe("a+b+c");
|
||||
expect(await run("StrUtil_LowerCase", { string: "AbC" })).toBe("abc");
|
||||
expect(await run("StrUtil_UpperCase", { string: "AbC" })).toBe("ABC");
|
||||
expect(await run("StrUtil_CaseChange", { string: "abc", case: "首字母大写" })).toBe("Abc");
|
||||
expect(await run("StrUtil_CaseChange", { string: "abc", case: "1" })).toBe("ABC");
|
||||
expect(await run("StrUtil_CaseChange", { string: "ABC", case: "2" })).toBe("abc");
|
||||
expect(await run("StrUtil_Trim", { string: " x " })).toBe("x");
|
||||
expect(await run("StrUtil_Left", { string: "hello", length: 2 })).toBe("he");
|
||||
expect(await run("StrUtil_Right", { string: "hello", length: 2 })).toBe("lo");
|
||||
expect(await run("StrUtil_Mid", { string: "hello", start: 1, length: 3 })).toBe("ell");
|
||||
expect(await run("StrUtil_CutOutStrV1", { string: "a[内容]b", start_cut_pos: "a[", end: "]" })).toContain("内容");
|
||||
expect(await run("StrUtil_Split", { string: "a,b,c", spliter: "," })).toEqual(["a", "b", "c"]);
|
||||
expect(await run("StrUtil_SplitStr", { string: "a; b ;c", diy_symbol: ";" })).toEqual(["a", "b", "c"]);
|
||||
expect(await run("StrUtil_AddToStr", { string: "尾", add_str: "头", add_type: "添加到前面", symbol: "-" })).toBe("头-尾");
|
||||
expect(await run("StrUtil_ReverseStr", { string: "ab cd", letter_reverse: true })).toBe("dc ba");
|
||||
expect(await run("StrUtil_ListToString", { str_list: ["a", "b"], connectors_custom: "|" })).toBe("a|b");
|
||||
expect(await run("StrUtil_CompleteStr", { string: "7", sum_length: 3, com_string: "0", com_type: "左边补" })).toBe("007");
|
||||
expect(await run("StrUtil_CharCode", { code: 65 })).toBe("A");
|
||||
expect(await run("StrUtil_CharAsc", { char: "A" })).toBe(65);
|
||||
expect(await run("StrUtil_CreateRandomStr", { string_length: 12 })).toHaveLength(12);
|
||||
console.log("[字符串族] 20 项通过");
|
||||
});
|
||||
|
||||
it("列表与集合族 List/Collection", async () => {
|
||||
expect(await run("List_CreateList", { data: JSON.stringify([1, 2]) })).toEqual([1, 2]);
|
||||
expect(await run("List_ListLength", { arrayRet: [1, 2, 3] })).toBe(3);
|
||||
expect(await run("List_InsertList", { arrayRet: ["a", "c"], data: "b", insertType: "指定位置", position: 1 })).toEqual(["a", "b", "c"]);
|
||||
expect(await run("List_DeleteList", { arrayRet: ["a", "b", "c"], data: "b" })).toEqual(["a", "c"]);
|
||||
expect(await run("List_DeleteList", { arrayRet: ["a", "b", "c"], deleteType: "按位置", position: 0 })).toEqual(["b", "c"]);
|
||||
expect(await run("List_GetListItem", { arrayRet: [10, 20], position: 1 })).toBe(20);
|
||||
expect(await run("List_SetListItem", { arrayRet: [1, 2], data: 9, position: 0 })).toEqual([9, 2]);
|
||||
expect(await run("List_GetIndex", { arrayRet: ["x", "y"], data: "y" })).toBe(1);
|
||||
expect(await run("List_ListMerge", { arrayRet1: [1], arrayRet2: [2, 3] })).toEqual([1, 2, 3]);
|
||||
expect(await run("List_ListDuplication", { arrayRet: [1, 1, 2] })).toEqual([1, 2]);
|
||||
expect(await run("List_ListSorted", { arrayRet: [3, 1, 2] })).toEqual([1, 2, 3]);
|
||||
expect(await run("List_ListIntersection", { arrayRet1: [1, 2, 3], arrayRet2: [2, 3, 4] })).toEqual([2, 3]);
|
||||
expect(await run("List_ClearList", {})).toEqual([]);
|
||||
expect(await run("Collection_Create", {})).toEqual([]);
|
||||
const withItem = await run("Collection_Add", { collection: [], value: "a" }) as unknown[];
|
||||
expect(withItem).toEqual(["a"]);
|
||||
expect(await run("Collection_HasItem", { collection: ["a"], item: "a" })).toBe(true);
|
||||
expect(await run("Collection_HasItem", { collection: ["a"], item: "z" })).toBe(false);
|
||||
expect(await run("Collection_Count", { collection: ["a", "b"] })).toBe(2);
|
||||
expect(await run("Collection_Remove", { collection: ["a", "b"], value: "a" })).toEqual(["b"]);
|
||||
console.log("[列表/集合族] 18 项通过");
|
||||
});
|
||||
|
||||
it("字典族 Dict", async () => {
|
||||
expect(await run("Dict_Create", { value: JSON.stringify({ a: 1 }) })).toEqual({ a: 1 });
|
||||
expect(await run("Dict_SetItem", { dictObj: { a: 1 }, key: "b", value: 2 })).toEqual({ a: 1, b: 2 });
|
||||
expect(await run("Dict_GetItem", { dictObj: { a: 1 }, key: "a" })).toBe(1);
|
||||
expect(await run("Dict_GetItem", { dictObj: {}, key: "x", default_value: "dft" })).toBe("dft");
|
||||
expect(await run("Dict_Keys", { dictObj: { a: 1, b: 2 } })).toEqual(["a", "b"]);
|
||||
expect(await run("Dict_Values", { dictObj: { a: 1, b: 2 } })).toEqual([1, 2]);
|
||||
expect(await run("Dict_Items", { dictObj: { a: 1 } })).toEqual([["a", 1]]);
|
||||
expect(await run("Dict_Delete", { dictObj: { a: 1, b: 2 }, key: "a" })).toEqual({ b: 2 });
|
||||
expect(await run("Dict_Update", { dictObj: { a: 1 }, targetDict: { b: 2 } })).toEqual({ a: 1, b: 2 });
|
||||
expect(await run("Dict_Clear", {})).toEqual({});
|
||||
console.log("[字典族] 10 项通过");
|
||||
});
|
||||
|
||||
it("数学族 Math 与正则族 Regex", async () => {
|
||||
expect(await run("Math_Abs", { data: -3.5 })).toBe(3.5);
|
||||
expect(await run("Math_Round", { data: 3.14159, digit: 2 })).toBe(3.14);
|
||||
expect(await run("Math_Int", { data: 3.2, position: "向上取整" })).toBe(4);
|
||||
expect(await run("Math_Sqrt", { data: 16 })).toBe(4);
|
||||
expect(await run("Math_Pow", { x: 2, y: 10 })).toBe(1024);
|
||||
expect(await run("Math_Sgn", { data: -9 })).toBe(-1);
|
||||
expect(await run("Math_Exp", { data: 0 })).toBe(1);
|
||||
expect(await run("Math_Hex", { num: 255 })).toBe("FF");
|
||||
expect(await run("Math_Oct", { num: 8 })).toBe("10");
|
||||
expect(await run("Math_Scale", { sourceNum: "1010", sourceNumScale: 2, targetNumScale: 10 })).toBe("10");
|
||||
expect(await run("Math_Sin", { data: 90, unit: "角度" })).toBeCloseTo(1, 5);
|
||||
expect(await run("Regex_Test", { text: "abc123", pattern: "\\d+" })).toBe(true);
|
||||
expect(await run("Regex_TestNew", { text: "abc", pattern: "\\d" })).toBe(false);
|
||||
expect(await run("Regex_FindAll", { text: "a1b22c333", pattern: "\\d+" })).toEqual(["1", "22", "333"]);
|
||||
expect(await run("Regex_FindStr", { text: "订单A-100,订单B-200", pattern: "订单[AB]-(\\d+)", index: 1 })).toBe("100");
|
||||
expect(await run("Regex_FindAllNew", { text: "2026-11-03", pattern: "\\d+" })).toEqual(["2026", "11", "03"]);
|
||||
console.log("[数学/正则族] 16 项通过");
|
||||
});
|
||||
|
||||
it("时间族 Time", async () => {
|
||||
expect(String(await run("Time_Now", {}))).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
expect(String(await run("Time_Date", {}))).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(String(await run("Time_Time", {}))).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
const ts = await run("Time_TimeStamp", {}) as number;
|
||||
expect(ts).toBeGreaterThan(1_700_000_000);
|
||||
expect(await run("Time_Format", { timestamp: 1767000000, format: "YYYY-MM-DD" })).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(await run("Time_DatetimeToTimestamp", { datetime: "2026-01-01 00:00:00" })).toBe(typeof 1_767_225_600 === "number" ? Math.floor(new Date("2026-01-01 00:00:00").getTime() / 1000) : 0);
|
||||
expect(await run("Time_Year", { datetime: "2026-11-03 10:20:30" })).toBe(2026);
|
||||
expect(await run("Time_Month", { datetime: "2026-11-03 10:20:30" })).toBe(11);
|
||||
expect(await run("Time_Day", { datetime: "2026-11-03 10:20:30" })).toBe(3);
|
||||
expect(await run("Time_Hour", { datetime: "2026-11-03 10:20:30" })).toBe(10);
|
||||
expect(await run("Time_Minute", { datetime: "2026-11-03 10:20:30" })).toBe(20);
|
||||
expect(await run("Time_Second", { datetime: "2026-11-03 10:20:30" })).toBe(30);
|
||||
expect(typeof (await run("Time_WeekDay", { datetime: "2026-11-03" }))).toBe("number");
|
||||
expect(await run("Time_DateOffset_V1", { date_input: "2026-11-01", offset_unit_num: 2, offset_status: "加", get_type: "YYYY-MM-DD" })).toBe("2026-11-03");
|
||||
expect(await run("Time_CalDate", { date_start: "2026-11-01", date_end: "2026-11-03", time_type: "天" })).toBeCloseTo(2, 5);
|
||||
expect(await run("Time_TimeTypeConversion", { datetime: "2026-11-03 08:00:00", format: "YYYY/MM/DD" })).toBe("2026/11/03");
|
||||
console.log("[时间族] 15 项通过");
|
||||
});
|
||||
|
||||
it("JSON 与 CSV 读写回环", async () => {
|
||||
expect(await run("JSON_Stringify", { string: { a: 1 } })).toBe('{"a":1}');
|
||||
expect(await run("JSON_Parse", { string: '{"a":1}' })).toEqual({ a: 1 });
|
||||
expect(await run("JSON_Parse", { string: "bad" })).toBeNull();
|
||||
const csvPath = path.join(sandbox, "t.csv");
|
||||
await run("CSV_WriteNew", { path: csvPath, table: [["名", "值"], ["a,b", "含\"引号\""], ["c", "d"]] });
|
||||
const back = await run("CSV_ReadNew", { path: csvPath }) as string[][];
|
||||
expect(back[0]).toEqual(["名", "值"]);
|
||||
expect(back[1]).toEqual(["a,b", '含"引号"']);
|
||||
console.log("[JSON/CSV] 通过(含引号逗号转义回环)");
|
||||
});
|
||||
|
||||
it("Ini 族完整生命周期", async () => {
|
||||
const iniPath = path.join(sandbox, "config.ini");
|
||||
await run("Ini_Write", { file: iniPath, section: "server", key: "host", value: "127.0.0.1" });
|
||||
await run("Ini_Write", { file: iniPath, section: "server", key: "port", value: "8080" });
|
||||
await run("Ini_Write", { file: iniPath, section: "log", key: "level", value: "info" });
|
||||
expect(await run("Ini_Read", { file: iniPath, section: "server", key: "host" })).toBe("127.0.0.1");
|
||||
expect(await run("Ini_ListSection", { file: iniPath })).toEqual(["server", "log"]);
|
||||
expect(await run("Ini_ListKey", { file: iniPath, section: "server" })).toEqual(["host", "port"]);
|
||||
expect(await run("Ini_DeleteKey", { file: iniPath, section: "server", key: "port" })).toBe(true);
|
||||
expect(await run("Ini_Read", { file: iniPath, section: "server", key: "port" })).toBe("");
|
||||
expect(await run("Ini_DeleteSection", { file: iniPath, section: "log" })).toBe(true);
|
||||
expect(await run("Ini_ListSection", { file: iniPath })).toEqual(["server"]);
|
||||
console.log("[Ini族] 写读列举删除全链通过");
|
||||
});
|
||||
|
||||
it("文件族全生命周期(沙盒目录)", async () => {
|
||||
const dir = path.join(sandbox, "files");
|
||||
const created = await run("File_CreateFile", { folder_path: dir, name: "a.txt" }) as string;
|
||||
expect(created).toContain("a.txt");
|
||||
await run("File_Write", { path: created, content: "第一行" });
|
||||
await run("File_Write", { path: created, content: "第二行", append: true });
|
||||
expect(await run("File_Read", { path: created })).toBe("第一行第二行");
|
||||
expect(await run("File_FileExists", { path: created })).toBe(true);
|
||||
expect(await run("File_PathExists", { path: path.join(dir, "nope.txt") })).toBe(false);
|
||||
expect(await run("File_PathSize", { path: created })).toBeGreaterThan(0);
|
||||
const info = await run("File_GetFileInfo", { file_path: created }) as { name: string; size: number };
|
||||
expect(info.name).toBe("a.txt");
|
||||
expect(info.size).toBeGreaterThan(0);
|
||||
const copy = path.join(dir, "b.txt");
|
||||
await run("File_Copy", { pathSrc: created, pathDest: copy });
|
||||
expect(await run("File_Read", { path: copy })).toBe("第一行第二行");
|
||||
const sub = path.join(dir, "sub");
|
||||
await run("File_CreateFolder", { path: sub });
|
||||
await run("File_Copy", { pathSrc: copy, pathDest: path.join(sub, "c.txt") });
|
||||
const flat = await run("File_GetFileList", { folder_path: dir }) as string[];
|
||||
expect(flat.length).toBe(2);
|
||||
const all = await run("File_GetFileList", { folder_path: dir, recursive: true }) as string[];
|
||||
expect(all.length).toBe(3);
|
||||
const kw = await run("File_GetFileList", { folder_path: dir, key_word: "a." }) as string[];
|
||||
expect(kw.length).toBe(1);
|
||||
const moved = await run("File_Move", { pathSrc: copy, pathDest: path.join(dir, "moved.txt") });
|
||||
expect(String(moved)).toContain("moved.txt");
|
||||
expect(await run("File_FileExists", { path: copy })).toBe(false);
|
||||
const renamed = await run("File_Rename", { pathSrc: moved, name: "renamed.txt" });
|
||||
expect(String(renamed)).toContain("renamed.txt");
|
||||
const zip = await run("File_CompressFile", { pathSrc: created, pathDest: path.join(sandbox, "pack.zip"), direct_type: "0" });
|
||||
expect(String(zip)).toContain("pack.zip");
|
||||
const unzipDir = path.join(sandbox, "unpacked");
|
||||
await run("File_DecompressFile", { path: path.join(sandbox, "pack.zip"), target: unzipDir, direct_type: "0" });
|
||||
expect(await run("File_FileExists", { path: path.join(unzipDir, "a.txt") })).toBe(true);
|
||||
expect(await run("File_WaitFile", { file_path: created, time_out: 1 })).toBe(true);
|
||||
await expect(run("File_WaitFile", { file_path: path.join(dir, "ghost.txt"), time_out: 1 })).rejects.toThrow(/超时/);
|
||||
await run("File_Delete", { file_path: renamed });
|
||||
expect(await run("File_FileExists", { path: renamed })).toBe(false);
|
||||
console.log("[文件族] 建/写/读/拷/列举/移/改名/压缩解压/等待/删除 全链通过");
|
||||
}, 60000);
|
||||
|
||||
it("数据表族 Bivartable", async () => {
|
||||
const table = [["部门", "姓名", "工资"], ["技术", "张三", 100], ["销售", "李四", 80], ["技术", "王五", 90], ["销售", "李四", 80]];
|
||||
const bodyOnly = [["技术", "张三", 100], ["销售", "李四", 80], ["技术", "王五", 90], ["销售", "李四", 80]];
|
||||
const withHeader = await run("Bivartable_DoCreate", { data: JSON.stringify(bodyOnly), columnList: JSON.stringify(["部门", "姓名", "工资"]) }) as unknown[][];
|
||||
expect(withHeader[0]).toEqual(["部门", "姓名", "工资"]);
|
||||
expect(withHeader.length).toBe(5);
|
||||
expect(await run("Bivartable_GetSumValue", { df: table, sumvaluecols: JSON.stringify(["工资"]) })).toBe(350);
|
||||
expect(await run("Bivartable_GetMaxValue", { df: table, maxvaluecols: JSON.stringify(["工资"]) })).toBe(100);
|
||||
expect(await run("Bivartable_GetMinValue", { df: table, minvaluecols: JSON.stringify(["工资"]) })).toBe(80);
|
||||
expect(await run("Bivartable_GetAvgValue", { df: table, avgvaluecols: JSON.stringify(["工资"]) })).toBe(87.5);
|
||||
const grouped = await run("Bivartable_GetSumValue", { df: table, sumvaluecols: JSON.stringify(["工资"]), groupby: JSON.stringify(["部门"]) }) as unknown[][];
|
||||
expect(grouped[0]).toEqual(["部门", "工资"]);
|
||||
expect(grouped).toContainEqual(["技术", 190]);
|
||||
expect(grouped).toContainEqual(["销售", 160]);
|
||||
const filtered = await run("Bivartable_DoFilter", { df: table, col_name: "部门", choice: "1", criteria: "技术" }) as unknown[][];
|
||||
expect(filtered.length).toBe(3);
|
||||
const expr = await run("Bivartable_DoFilter", { df: table, rule: '["工资"] > 85' }) as unknown[][];
|
||||
expect(expr.length).toBe(3);
|
||||
const dedup = await run("Bivartable_DeleteDuplicateRow", { df: table }) as unknown[][];
|
||||
expect(dedup.length).toBe(4);
|
||||
console.log("[数据表族] 创建/聚合(含分组)/过滤(简单+表达式)/去重 通过");
|
||||
});
|
||||
|
||||
it("编排控制与日志", async () => {
|
||||
const varsCtx = { ...ctx, vars: {} } as unknown as RunContext;
|
||||
expect(await run("logic_control_var", { var_name: "k1", var_value: "v1" }, varsCtx)).toBe("v1");
|
||||
expect((varsCtx as unknown as { vars: Record<string, unknown> }).vars.k1).toBe("v1");
|
||||
await expect(run("logic_control_var", {})).rejects.toThrow(/变量名称/);
|
||||
await expect(run("logic_control_break_for", {})).rejects.toBeTruthy();
|
||||
await expect(run("logic_control_next_for", {})).rejects.toBeTruthy();
|
||||
await expect(run("logic_control_stop", {})).rejects.toBeTruthy();
|
||||
await expect(run("logic_control_stop_return", {})).rejects.toBeTruthy();
|
||||
expect(await run("Trigger.Manual", {})).toBeNull();
|
||||
expect(await run("Trigger.Schedule", {})).toBeNull();
|
||||
expect(await run("Trigger.FileArrival", {})).toBeNull();
|
||||
const logs: Array<{ m: string; lv: string }> = [];
|
||||
const logCtx = { ...ctx, log: (m: string, lv: string) => logs.push({ m, lv }) } as unknown as RunContext;
|
||||
expect(await run("Log_Info", { msg: "提示信息" }, logCtx)).toBe("提示信息");
|
||||
expect(await run("Log_Error", { msg: "错误信息" }, logCtx)).toBe("错误信息");
|
||||
expect(logs.some((l) => l.lv === "error")).toBe(true);
|
||||
await expect(run("ExceptionUtil_Throws", { msg: "自定义异常" })).rejects.toThrow(/自定义异常/);
|
||||
console.log("[编排/日志] 变量/循环控制信号/触发器/日志/自定义异常 通过");
|
||||
});
|
||||
|
||||
it("全量健康检查: 已注册组件空参调用不产生非结构化崩溃", async () => {
|
||||
const ids: string[] = capabilityIds();
|
||||
const skip = new Set([
|
||||
"FTP_Connect", "FTP_Upload", "FTP_Download", "FTP_Delete", "FTP_Rename", "FTP_Cwd", "FTP_GetFileList", "FTP_CreateDir", "FTP_Exist", "FTP_Disconnect",
|
||||
"Database_DBConnect", "Database_SQLExecute", "Database_SingleSQLQuery", "Database_AllSQLQuery_V1", "Database_BatchSQLExecute", "Database_BatchInsertExecute", "Database_CloseDBConnect",
|
||||
"File_OpenFile", "File_StartBusiness", "File_UploadBusinessResults", "File_UploadBusinessResults_v1", "File_Collectupload",
|
||||
"File_Delete", "File_DeleteFile", "File_EmptyFolder", "File_WaitFile",
|
||||
"logic_control_break_for", "logic_control_next_for", "logic_control_stop", "logic_control_stop_return",
|
||||
"child_modules_v1", "concurrent_child_process_v1", "Element_exception_monitor_v2", "logic_control_if_ai_vision"
|
||||
]);
|
||||
const crashes: Array<{ id: string; error: string }> = [];
|
||||
let checked = 0;
|
||||
for (const id of ids) {
|
||||
const handler = getHandler(id);
|
||||
if (!handler || skip.has(id)) continue;
|
||||
checked += 1;
|
||||
try {
|
||||
await handler(ctx, { ...node, componentId: id } as unknown as FlowNode, {}, {} as CatalogComponent);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuntimeError)) {
|
||||
crashes.push({ id, error: `${(error as Error).name}: ${(error as Error).message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[全量健康检查] 空参调用 ${checked} 个已注册组件, 结构化报错全部正常, 非结构化崩溃 ${crashes.length} 个`);
|
||||
if (crashes.length) console.log(crashes.map((c) => `${c.id}: ${c.error}`).join("\n"));
|
||||
expect(crashes).toEqual([]);
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./data-table-common";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const context = { vars: {} } as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||
afterEach(async () => { context.vars = {}; await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); });
|
||||
|
||||
describe("common data table capabilities", () => {
|
||||
it("reads, writes, calculates and joins columns", async () => {
|
||||
const source = [["A", "B"], [1, 2], [3, 4]];
|
||||
await expect(run("Bivartable_ReadDfData", { df: source, readRange: "cell", row_num: 2, col_name: "B" })).resolves.toBe(2);
|
||||
await expect(run("Bivartable_ColumnOperationDf", { df: source, name1: "A", name2: "B", operator: "+", name: "Total" })).resolves.toEqual([["A", "B", "Total"], [1, 2, 3], [3, 4, 7]]);
|
||||
await expect(run("Bivartable_DoJoint", { df: source, cola: "A", colb: "B", colnew: "AB" })).resolves.toEqual([["A", "B", "AB"], [1, 2, "12"], [3, 4, "34"]]);
|
||||
});
|
||||
|
||||
it("fills missing values and modifies column names", async () => {
|
||||
await expect(run("Bivartable_HandleDfMissValue", { df: [["A"], [null], [2]], handleType: "1", value: 0 })).resolves.toEqual([["A"], [0], [2]]);
|
||||
await expect(run("Bivartable_ModifyColName", { df: [["A"], [1]], srcname: "A", newname: "Value" })).resolves.toEqual([["Value"], [1]]);
|
||||
});
|
||||
|
||||
it("converts column types and builds a pivot table", async () => {
|
||||
await expect(run("Bivartable_Dtype", { df: [["id", "amount"], ["1", "2.5"]], change_type: "name", col_name: ["id", "amount"], dtype: "float" })).resolves.toEqual([["id", "amount"], [1, 2.5]]);
|
||||
const source = [["team", "month", "amount"], ["A", "Jan", 2], ["A", "Feb", 3], ["B", "Jan", 4]];
|
||||
await expect(run("Bivartable_PivotDf", { df: source, row_list: ["team"], col_list: ["month"], operator: { amount: "sum" }, value: 0 })).resolves.toEqual([["team", "Jan", "Feb"], ["A", 2, 3], ["B", 4, 0]]);
|
||||
});
|
||||
|
||||
it("exports and imports Excel and databook metadata", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-table-common-")); dirs.push(directory); const file = path.join(directory, "table.xlsx");
|
||||
await run("Bivartable_DoSave", { df: [["A", "B"], [1, 2]], filePath: file });
|
||||
await expect(run("Bivartable_DoOpenExcel", { filePath: file, sheetName: "Sheet1" })).resolves.toEqual([["A", "B"], [1, 2]]);
|
||||
await run("Databook_Import", { excel_path: file, sheet_name: "Sheet1" });
|
||||
await run("Databook_SetColumnInfo", { column_name: "A", column_description: "编号" });
|
||||
await expect(run("Databook_GetColumnInfo", { column_name: "A" })).resolves.toBe("编号");
|
||||
await expect(run("Databook_Export", { export_path: directory, export_name: "book.xlsx" })).resolves.toBe(path.join(directory, "book.xlsx"));
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"clicked":true,"value":"cached"}'));
|
||||
|
||||
vi.mock("../driver/windows/powershell", () => ({
|
||||
powershell: powershellMock,
|
||||
psString: (value: unknown) => JSON.stringify(String(value ?? ""))
|
||||
}));
|
||||
|
||||
import { getHandler } from "../engine/registry";
|
||||
import { parseUiaSelector } from "./desktop-uia";
|
||||
|
||||
|
||||
describe("desktop UIA ground capabilities", () => {
|
||||
it.each([
|
||||
"Element_ClickGround",
|
||||
"Element_SetValueGround",
|
||||
"Element_ExistsGround",
|
||||
"Element_GetValueGround",
|
||||
"Element_WaitElementGround",
|
||||
"Element_GetCheckGround",
|
||||
"Element_SetCheckGround",
|
||||
"Element_RegionGround",
|
||||
"Element_GetSelectGround",
|
||||
"Element_SetSelectGround",
|
||||
"Element_GetAttributeGround",
|
||||
"Element_SetAttributeGround",
|
||||
"Element_GetButtonEnableGround",
|
||||
"Element_ShotGround",
|
||||
"Element_MouseToGround",
|
||||
"Element_ScrollIntoViewGround",
|
||||
"Element_ChildrenGround",
|
||||
"Element_DesktopChildren",
|
||||
"Element_RestoreWindowGround",
|
||||
"Element_ActivateWindowGround",
|
||||
"Element_MoveWindowGround"
|
||||
])("registers %s", (id) => {
|
||||
expect(getHandler(id), id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("parses name selector", () => {
|
||||
const s = parseUiaSelector("name=添加供应商");
|
||||
expect(s.name).toBe("添加供应商");
|
||||
expect(s.index).toBe(1);
|
||||
});
|
||||
|
||||
it("parses window, id, type, class and index", () => {
|
||||
const s = parseUiaSelector("window=CC Switch;id=btn-save;type=button;class=btn;index=2");
|
||||
expect(s.window).toBe("CC Switch");
|
||||
expect(s.id).toBe("btn-save");
|
||||
expect(s.type).toBe("Button");
|
||||
expect(s.className).toBe("btn");
|
||||
expect(s.index).toBe(2);
|
||||
});
|
||||
|
||||
it("strips uia: prefix and accepts object form", () => {
|
||||
const s = parseUiaSelector({ value: "uia:name=确定" });
|
||||
expect(s.name).toBe("确定");
|
||||
});
|
||||
|
||||
it("parses AI screen coordinate selector", () => {
|
||||
const s = parseUiaSelector("screen=100,200,320,80;window= MyApp;ai=1");
|
||||
expect(s.screen).toEqual({ x: 100, y: 200, w: 320, h: 80 });
|
||||
expect(s.window).toBe("MyApp");
|
||||
});
|
||||
|
||||
it("parses enriched AI relocation metadata", () => {
|
||||
const selector = parseUiaSelector(
|
||||
"screen=100,200,320,80;window=App;winClass=Chrome_WidgetWin_1;process=chrome;rel=20,30,320,80;name=login;id=loginButton;type=Button;class=Button;aiType=button;aiText=login;enriched=1;ai=1"
|
||||
);
|
||||
expect(selector.screen).toEqual({ x: 100, y: 200, w: 320, h: 80 });
|
||||
expect(selector.rel).toEqual({ x: 20, y: 30, w: 320, h: 80 });
|
||||
expect(selector.window).toBe("App");
|
||||
expect(selector.winClass).toBe("Chrome_WidgetWin_1");
|
||||
expect(selector.process).toBe("chrome");
|
||||
expect(selector).toMatchObject({ name: "login", id: "loginButton", type: "Button", className: "Button", aiType: "button", aiText: "login", enriched: true, ai: true });
|
||||
});
|
||||
|
||||
it("rejects invalid selector", () => {
|
||||
expect(() => parseUiaSelector("")).toThrow();
|
||||
expect(() => parseUiaSelector("screen=1,2,3")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop UIA selector compatibility", () => {
|
||||
beforeEach(() => {
|
||||
powershellMock.mockClear();
|
||||
powershellMock.mockImplementation(async () => '{"clicked":true,"value":"cached"}');
|
||||
});
|
||||
|
||||
it("maps compound .NET ControlType names explicitly and preserves raw input", () => {
|
||||
expect(parseUiaSelector("type=combobox")).toMatchObject({ type: "ComboBox", typeRaw: "combobox" });
|
||||
expect(parseUiaSelector("type=listitem")).toMatchObject({ type: "ListItem", typeRaw: "listitem" });
|
||||
expect(parseUiaSelector("type=datagrid")).toMatchObject({ type: "DataGrid", typeRaw: "datagrid" });
|
||||
});
|
||||
|
||||
it("parses a stable ancestor path", () => {
|
||||
const selector = parseUiaSelector("window=App;path=Pane>Edit[name=用户名;index=2]>Button[name=确定]");
|
||||
expect(selector.path).toEqual([
|
||||
{ type: "Pane" },
|
||||
{ type: "Edit", name: "用户名", index: 2 },
|
||||
{ type: "Button", name: "确定" }
|
||||
]);
|
||||
});
|
||||
|
||||
const context = { log: vi.fn() } as any;
|
||||
const node = { id: "n1", componentId: "Element_ClickGround" } as any;
|
||||
|
||||
it("resolves screen selectors through UIA/window before coordinate fallback", async () => {
|
||||
const args = {
|
||||
selector: "screen=100,200,320,80;ai=1",
|
||||
clickway: "1"
|
||||
};
|
||||
await expect(getHandler("Element_ClickGround")!(context, node, args, {} as any)).resolves.toEqual({ clicked: true, value: "cached" });
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[0]?.[0] ?? "");
|
||||
expect(script).toContain("RpaScreenInput");
|
||||
expect(script).toContain("$x = [int]($rect.x + $rect.w / 2)");
|
||||
expect(script).toContain("SetCursorPos($x, $y)");
|
||||
expect(script).toContain("Resolve-RpaScreenRect");
|
||||
expect(script).toContain("Find-RpaWindow");
|
||||
});
|
||||
|
||||
it("元素定位到但 clicked=false 时必须抛错,不允许静默成功(用户 NVIDIA 覆盖层踩坑场景)", async () => {
|
||||
powershellMock.mockImplementation(async () => '{"clicked":false,"method":"invoke_pattern","name":"","x":0,"y":0,"w":1919,"h":1080}');
|
||||
let error: any = null;
|
||||
try {
|
||||
await getHandler("Element_ClickGround")!(
|
||||
context,
|
||||
node,
|
||||
{ selector: "window=NVIDIA GeForce Overlay DT;type=Document;id=RootWebArea" },
|
||||
{} as any
|
||||
);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error?.code).toBe("desktop_error");
|
||||
// recommendation 必须给出可执行建议
|
||||
expect(String(error?.recommendation ?? "")).toContain("桌面拾取");
|
||||
});
|
||||
|
||||
it("dispatches screen setValue to coordinate paste", async () => {
|
||||
await getHandler("Element_SetValueGround")!(context, node, {
|
||||
selector: "screen=10,20,30,40",
|
||||
value: "hello"
|
||||
}, {} as any);
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[0]?.[0] ?? "");
|
||||
expect(script).toContain("resolvedMethod = $rect.method");
|
||||
expect(script).toContain("hello");
|
||||
expect(script).toContain("Find-RpaWindow");
|
||||
});
|
||||
|
||||
it("validates a picked screen rectangle for exists/wait", async () => {
|
||||
powershellMock.mockImplementation(async () => '{"found":true,"method":"window_rel"}');
|
||||
await expect(getHandler("Element_ExistsGround")!(context, node, {
|
||||
selector: "screen=1,2,3,4"
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
await expect(getHandler("Element_WaitElementGround")!(context, node, {
|
||||
selector: "screen=1,2,3,4"
|
||||
}, {} as any)).resolves.toBe(true);
|
||||
expect(powershellMock).toHaveBeenCalled();
|
||||
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
|
||||
expect(script).toContain("Resolve-RpaScreenRect");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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 ExcelJS from "exceljs";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./excel-com";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, id).toBeTruthy();
|
||||
return handler!(context, node(id), args, component);
|
||||
}
|
||||
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
|
||||
|
||||
const registered = [
|
||||
"Excel_Cs_Group", "Excel_Cs_DeletePicture", "Excel_Cs_RefreshPivotTable", "Excel_Cs_DeletePictureV2",
|
||||
"Excel_Cs_Encrypt", "Excel_Cs_Move", "Excel_Cs_InsertPicture", "Excel_Cs_Merge", "Excel_InsertImage",
|
||||
"Excel_Cs_InsertPivotView", "Excel_Cs_Interop", "Excel_Cs_InsertPivotTable", "Excel_Cs_RunMacro",
|
||||
"Excel_Loop", "Excel_Cs_Export", "Excel_Cs_ExcelAddin", "Excel_Cs_InsertPictureV2",
|
||||
"Excel_Cs_FilterPivotTable", "Excel_Cs_RunMacroV2", "Excel_Cs_SetSlicer", "Excel_ExecuteMacro",
|
||||
"Excel_Cs_RefreshAll", "Excel_DeleteImage", "Excel_ExportRangeAsPicture"
|
||||
];
|
||||
|
||||
describe("Excel COM capabilities", () => {
|
||||
it("registers every Excel COM capability", () => { for (const id of registered) expect(getHandler(id), id).toBeTruthy(); });
|
||||
|
||||
it("groups rows, moves sheets, reads dynamic properties and loops content", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-com-")); directories.push(directory);
|
||||
const filePath = path.join(directory, "workbook.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.addWorksheet("Sheet1").getCell("A1").value = "A1";
|
||||
workbook.addWorksheet("Second").getCell("A1").value = "S1";
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
|
||||
await expect(run("Excel_Cs_Group", { workbook: { path: filePath }, sheet: "Sheet1", group: 0, groupType: 0, startRow: 1, endRow: 2, save: true })).resolves.toMatchObject({ grouped: true });
|
||||
await expect(run("Excel_Cs_Interop", { workbook: { path: filePath }, obj: 2, sheet: "Sheet1", property: "Name" })).resolves.toMatchObject({ value: "Sheet1" });
|
||||
await expect(run("Excel_Cs_Move", { workbook: { path: filePath }, sheet: "Second", mode: "按序号", index: 1, save: true })).resolves.toMatchObject({ moved: true });
|
||||
await expect(run("Excel_Cs_RefreshAll", { workbook: { path: filePath }, save: true })).resolves.toMatchObject({ refreshed: true });
|
||||
const loop = await run("Excel_Loop", { objExcel: { path: filePath } }) as unknown[][];
|
||||
expect(loop.length).toBeGreaterThanOrEqual(2);
|
||||
expect(loop.flat().join(",")).toContain("A1");
|
||||
}, 120000);
|
||||
|
||||
it("exports a workbook to PDF through Excel COM", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-pdf-")); directories.push(directory);
|
||||
const filePath = path.join(directory, "source.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.addWorksheet("Report").getCell("A1").value = "PDF";
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
const output = await run("Excel_Cs_Export", { workbook: { path: filePath }, option: "全部", path: directory, name: "report.pdf", cover: true }) as string;
|
||||
expect(output).toBe(path.join(directory, "report.pdf"));
|
||||
expect((await readFile(output)).subarray(0, 5).toString()).toMatch(/^%PDF-/);
|
||||
}, 120000);
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
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 { Catalog, CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./index";
|
||||
import { EXCEL_COMMON_IDS } from "./excel-common";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (componentId: string): FlowNode => ({
|
||||
id: `excel_common_${componentId}`,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {}
|
||||
});
|
||||
|
||||
async function temporaryWorkbook(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-common-"));
|
||||
directories.push(directory);
|
||||
const filePath = path.join(directory, "common.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("Data");
|
||||
sheet.addRows([
|
||||
["name", "score", "group", "parts"],
|
||||
["Bob", 2, "B", "x,y"],
|
||||
["Alice", 3, "A", "m,n"],
|
||||
["Bob", 2, "B", "p,q"]
|
||||
]);
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function readWorkbook(filePath: string): Promise<ExcelJS.Workbook> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(filePath);
|
||||
return workbook;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("common Excel capabilities", () => {
|
||||
it("registers at least 25 real catalog IDs without conflicting with existing modules", async () => {
|
||||
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
|
||||
const catalogIds = new Set(catalog.components.map((item) => item.id));
|
||||
expect(EXCEL_COMMON_IDS.length).toBeGreaterThanOrEqual(25);
|
||||
for (const id of EXCEL_COMMON_IDS) {
|
||||
expect(catalogIds.has(id), `${id} should exist in catalog.json`).toBe(true);
|
||||
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
|
||||
}
|
||||
|
||||
const sourceFiles = (await readdir("src/main/capabilities"))
|
||||
.filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts") && file !== "excel-common.ts");
|
||||
const existingSource = (await Promise.all(sourceFiles.map((file) => readFile(path.join("src/main/capabilities", file), "utf8")))).join("\n");
|
||||
for (const id of EXCEL_COMMON_IDS) expect(existingSource.includes(`"${id}"`), `${id} must not already be registered`).toBe(false);
|
||||
});
|
||||
|
||||
it("writes and reads rows, columns, and ranges in a real workbook", async () => {
|
||||
const filePath = await temporaryWorkbook();
|
||||
await run("Excel_Cs_WriteRow", { workbook: filePath, sheet: "Data", row: 6, column: "A", contents: ["Carol", 9, "C"], save: 1 });
|
||||
await run("Excel_Cs_WriteColumn", { workbook: filePath, sheet: "Data", row: 2, column: "E", contents: [10, 20, 30], save: 1 });
|
||||
await run("Excel_Cs_WriteRangeV2", { workbook: filePath, sheet: "Data", row: 8, column: "A", contents: [["x", 1], ["y", 2]], save: 1 });
|
||||
|
||||
await expect(run("Excel_Cs_ReadRow", { workbook: filePath, sheet: "Data", rowIndex: 6 })).resolves.toEqual(["Carol", 9, "C", "", ""]);
|
||||
await expect(run("Excel_GetColData", { objExcel: filePath, sheet: "Data", cellPos: "E2" })).resolves.toEqual([10, 20, 30, "", "", "", "", ""]);
|
||||
await expect(run("Excel_Cs_ReadRange", { workbook: filePath, sheet: "Data", startRow: 8, startColumn: "A", lastRow: 9, lastColumn: "B" }))
|
||||
.resolves.toEqual([["x", 1], ["y", 2]]);
|
||||
await expect(run("Excel_Cs_ReadColumnCount", { workbook: filePath, sheet: "Data", row: 2 })).resolves.toBe(5);
|
||||
});
|
||||
|
||||
it("finds, replaces, sorts, filters, clears filters, and removes duplicates", async () => {
|
||||
const filePath = await temporaryWorkbook();
|
||||
await expect(run("Excel_Cs_Find", { workbook: filePath, sheet: "Data", what: "Alice", matchFirst: 1 }))
|
||||
.resolves.toEqual(["A3"]);
|
||||
await run("Excel_Cs_Search", { workbook: filePath, sheet: "Data", mode: "替换", input: "Alice", replace: "Alicia", wholeWord: 1, save: 1 });
|
||||
await run("Excel_Cs_Sort", { workbook: filePath, sheet: "Data", sortRange: "A1:D4", sortOrder: "2", header: 1, save: 1 });
|
||||
await run("Excel_Cs_Filter", { workbook: filePath, sheet: "Data", column: "C", criteria: "B", condition: "equal", startRow: 1, endRow: 4, onHeader: 1, save: 1 });
|
||||
|
||||
let workbook = await readWorkbook(filePath);
|
||||
let sheet = workbook.getWorksheet("Data")!;
|
||||
expect(sheet.getCell("A2").value).toBe("Bob");
|
||||
expect(sheet.getRow(3).hidden || sheet.getRow(4).hidden).toBe(true);
|
||||
|
||||
await run("Excel_Cs_ClearExcelFilter", { workbook: filePath, sheet: "Data", save: 1 });
|
||||
await run("Excel_Cs_DeleteDuplicates", { workbook: filePath, sheet: "Data", columns: ["A", "B", "C"], header: 1, save: 1 });
|
||||
workbook = await readWorkbook(filePath);
|
||||
sheet = workbook.getWorksheet("Data")!;
|
||||
expect(sheet.getRows(1, sheet.rowCount)!.some((row) => row.hidden)).toBe(false);
|
||||
expect(sheet.rowCount).toBe(3);
|
||||
expect(sheet.getColumn("A").values).toContain("Alicia");
|
||||
});
|
||||
|
||||
it("copies, pastes, fills, splits, and deletes real ranges", async () => {
|
||||
const filePath = await temporaryWorkbook();
|
||||
await run("Excel_CopyRange", { objExcel: filePath, sheet: "Data", cellPosRange: "A2:B3" });
|
||||
await run("Excel_Cs_PastRange", { workbook: filePath, sheet: "Data", column: "F", row: 2, save: 1 });
|
||||
await run("Excel_Cs_AutoFill", { workbook: filePath, sheet: "Data", sourceRange: "B2:B3", destination: "B2:B6", save: 1 });
|
||||
await run("Excel_Cs_ColumnSplitV2", { workbook: filePath, sheet: "Data", column: "D", destination: "J1", comma: 1, tab: 0, startRow: 2, endRow: 4, save: 1 });
|
||||
await run("Excel_Cs_DeleteRangeV3", { workbook: filePath, sheet: "Data", excelRng: "C2:C4", save: 1 });
|
||||
|
||||
const workbook = await readWorkbook(filePath);
|
||||
const sheet = workbook.getWorksheet("Data")!;
|
||||
expect(sheet.getCell("F2").value).toBe("Bob");
|
||||
expect(sheet.getCell("G3").value).toBe(3);
|
||||
expect(sheet.getCell("B6").value).toBe(6);
|
||||
expect(sheet.getCell("J2").value).toBe("x");
|
||||
expect(sheet.getCell("K2").value).toBe("y");
|
||||
expect(sheet.getCell("C2").value).toBeNull();
|
||||
});
|
||||
|
||||
it("manages sheets and persists active sheet state", async () => {
|
||||
const filePath = await temporaryWorkbook();
|
||||
await run("Excel_CreateSheet", { objExcel: filePath, sheet: "Report", isSave: true });
|
||||
await expect(run("Excel_Cs_GetName", { workbook: filePath })).resolves.toEqual(["Data", "Report"]);
|
||||
await expect(run("Excel_Cs_Active", { workbook: filePath, mode: "按名称", sheet: "Report", save: 1 })).resolves.toBe("Report");
|
||||
await run("Excel_DeleteSheet", { objExcel: filePath, sheet: "Data", isSave: true });
|
||||
await run("Excel_Cs_Close", { workbook: filePath, save: 1 });
|
||||
|
||||
const workbook = await readWorkbook(filePath);
|
||||
expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual(["Report"]);
|
||||
});
|
||||
|
||||
it("sets and reads cell and range colors", async () => {
|
||||
const filePath = await temporaryWorkbook();
|
||||
await run("Excel_Cs_SetCellBackground", { workbook: filePath, sheet: "Data", column: "A", row: 2, color: "#12AB34", save: 1 });
|
||||
await run("Excel_Cs_SetRangeFontV2", { workbook: filePath, sheet: "Data", range: "A2:B3", color: "#445566", save: 1 });
|
||||
await expect(run("Excel_Cs_ReadCellBackground", { workbook: filePath, sheet: "Data", column: "A", row: 2 })).resolves.toBe("FF12AB34");
|
||||
|
||||
const workbook = await readWorkbook(filePath);
|
||||
const sheet = workbook.getWorksheet("Data")!;
|
||||
expect((sheet.getCell("A2").fill as ExcelJS.FillPattern).fgColor?.argb).toBe("FF12AB34");
|
||||
expect(sheet.getCell("B3").font.color?.argb).toBe("FF445566");
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { mkdtemp, 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 "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./office";
|
||||
import "./extended";
|
||||
import "./office-file-extended";
|
||||
import "./office-aliases";
|
||||
import "./excel-common";
|
||||
import "./common-final";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||
afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))));
|
||||
|
||||
describe("excel shared workbook store", () => {
|
||||
it("keeps widths/heights set by office-file-extended when excel-common saves afterwards", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
|
||||
const handle = await run("Excel_NewExcel", { filePath: directory, name: "a.xlsx", isMakedirs: "True" }) as { path: string };
|
||||
await run("Excel_Cs_WriteRangeV2", { workbook: handle, sheet: "Sheet1", row: "1", column: "A", contents: [["名称", "数量", "备注"], ["鼠标", 20, "A;B"]], isSave: "True" });
|
||||
await run("Excel_Cs_SetWidth", { workbook: handle, sheet: "Sheet1", columns: "A:C", width: "20", save: "True" });
|
||||
await run("Excel_Cs_SetHeight", { workbook: handle, sheet: "Sheet1", rows: "1", height: "26", save: "True" });
|
||||
await run("Excel_Cs_MergeCells", { workbook: handle, sheet: "Sheet1", range: "A1:C1", merge: "0", save: "True" });
|
||||
await run("Excel_Cs_SetRangeBackground", { workbook: handle, sheet: "Sheet1", range: "A1:C1", color: "FFF2CC", save: "True" });
|
||||
await run("Excel_SetCellFontColor", { objExcel: handle, sheet: "Sheet1", cellPos: "A1", cellColor: "FF0000", isSave: "True" });
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile(handle.path);
|
||||
const ws = wb.worksheets[0];
|
||||
expect(ws.getColumn("A").width).toBe(20);
|
||||
expect(ws.getRow(1).height).toBe(26);
|
||||
expect(ws.model.merges).toContain("A1:C1");
|
||||
expect((ws.getCell("A1").fill as ExcelJS.FillPattern).fgColor?.argb).toBe("FFFFF2CC");
|
||||
expect((ws.getCell("A1").font?.color as { argb?: string } | undefined)?.argb).toBe("FFFF0000");
|
||||
expect(ws.getCell("A1").value).toBe("名称");
|
||||
});
|
||||
|
||||
it("keeps copied sheets created by office-file-extended when sheets are created and deleted later", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
|
||||
const handle = await run("Excel_NewExcel", { filePath: directory, name: "b.xlsx", isMakedirs: "True" }) as { path: string };
|
||||
await run("Excel_Cs_WriteRangeV2", { workbook: handle, sheet: "Sheet1", row: "1", column: "A", contents: [["名称"]], isSave: "True" });
|
||||
await run("Excel_Cs_CopyV2", { workbook: handle, sheet: "Sheet1", newSheet: "Sheet1副本", override: "True", save: "True" });
|
||||
await run("Excel_CreateSheet", { workbook: handle, sheet: "临时表", save: "True" });
|
||||
await run("Excel_DeleteSheet", { workbook: handle, sheet: "临时表", save: "True" });
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile(handle.path);
|
||||
expect(wb.worksheets.map((sheet) => sheet.name)).toEqual(["Sheet1", "Sheet1副本"]);
|
||||
});
|
||||
|
||||
it("keeps the shared instance usable after save-as switches the handle path", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-store-")); dirs.push(directory);
|
||||
const handle = await run("Excel_NewExcel", { filePath: directory, name: "c.xlsx", isMakedirs: "True" }) as { path: string };
|
||||
await run("Excel_Cs_SetWidth", { workbook: handle, sheet: "Sheet1", columns: "A", width: "18", save: "True" });
|
||||
const saved = await run("Excel_Cs_SaveAs", { workbook: handle, path: path.join(directory, "d.xlsx") }) as { path: string };
|
||||
expect(saved.path).toBe(path.resolve(directory, "d.xlsx"));
|
||||
await run("Excel_Cs_SetHeight", { workbook: handle, sheet: "Sheet1", rows: "1", height: "30", save: "True" });
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile(path.resolve(directory, "d.xlsx"));
|
||||
expect(wb.worksheets[0].getColumn("A").width).toBe(18);
|
||||
expect(wb.worksheets[0].getRow(1).height).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
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 "../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"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./files";
|
||||
|
||||
function run(id: string, args: Record<string, unknown>): Promise<string> {
|
||||
const node: FlowNode = { id: "file-test", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
|
||||
const handler = getHandler(id)!;
|
||||
return Promise.resolve(handler({ signal: new AbortController().signal, log: vi.fn(), vars: {} } as unknown as RunContext, node, args, {} as CatalogComponent)) as Promise<string>;
|
||||
}
|
||||
|
||||
const temporary: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of temporary.splice(0)) rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("file compression path semantics", () => {
|
||||
it("defaults to a sibling archive and creates a copy instead of overwriting", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-compress-"));
|
||||
temporary.push(dir);
|
||||
const source = path.join(dir, "report.txt");
|
||||
await writeFile(source, "hello", "utf8");
|
||||
const first = await run("File_CompressFile", { file_type: "openFile", file_path: source, direct_type: "1" });
|
||||
expect(first).toBe(path.join(dir, "report.txt.zip"));
|
||||
expect(existsSync(first)).toBe(true);
|
||||
const second = await run("File_CompressFile", { file_type: "openFile", file_path: source, direct_type: "1" });
|
||||
expect(second).toBe(path.join(dir, "report.txt (1).zip"));
|
||||
expect(existsSync(second)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an output directory when custom destination is selected", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-compress-"));
|
||||
temporary.push(dir);
|
||||
const source = path.join(dir, "logs");
|
||||
const output = path.join(dir, "output");
|
||||
await Promise.all([mkdir(source), mkdir(output)]);
|
||||
await writeFile(path.join(source, "app.log"), "log", "utf8");
|
||||
const target = await run("File_CompressFile", {
|
||||
file_type: "openDirectory", folder_path: source, direct_type: "0", out_path: output
|
||||
});
|
||||
expect(target).toBe(path.join(output, "logs.zip"));
|
||||
expect(existsSync(target)).toBe(true);
|
||||
});
|
||||
|
||||
it("extracts beside the archive by default and to the selected folder for custom mode", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-extract-"));
|
||||
temporary.push(dir);
|
||||
const source = path.join(dir, "logs.zip");
|
||||
const AdmZip = (await import("adm-zip")).default;
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("app.log", Buffer.from("log", "utf8"));
|
||||
zip.writeZip(source);
|
||||
const defaultTarget = await run("File_DecompressFile", { folder_path: source, direct_type: "1" });
|
||||
expect(defaultTarget).toBe(dir);
|
||||
expect(readFileSync(path.join(dir, "app.log"), "utf8")).toBe("log");
|
||||
|
||||
const custom = path.join(dir, "custom-output");
|
||||
const customTarget = await run("File_DecompressFile", { folder_path: source, direct_type: "0", out_path: custom });
|
||||
expect(customTarget).toBe(custom);
|
||||
expect(readdirSync(custom)).toContain("app.log");
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./desktop";
|
||||
import "./common-final";
|
||||
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id: "smoke-kb", componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
|
||||
const ctx = {
|
||||
runId: "kb-smoke", flowId: "kb-smoke-flow", flow: {} as RunContext["flow"],
|
||||
vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: "D:/office-test2",
|
||||
emit: () => {}, log: () => {}, componentOf: () => undefined
|
||||
} as unknown as RunContext;
|
||||
|
||||
async function invoke(componentId: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const handler = getHandler(componentId);
|
||||
expect(handler).toBeTypeOf("function");
|
||||
return handler!(ctx, node(componentId), args, component);
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform !== "win32")("键盘输入组件真实冒烟", () => {
|
||||
it("words 字段取值(此前读不到报「键盘输入为空」), 后台接口模式整段发送特殊字符不抛错", async () => {
|
||||
const out = await invoke("Keyboard_Send_V1", { words: "哈哈哈(测试)100%^2", interval: "10", fuctype: "0" });
|
||||
expect(out).toBe("哈哈哈(测试)100%^2");
|
||||
console.log("[键盘输入] 后台接口模式整段发送 ✓:", out);
|
||||
}, 60000);
|
||||
|
||||
it("人工模拟模式逐字符+间隔毫秒真实发送", async () => {
|
||||
const out = await invoke("Keyboard_Send_V1", { words: "逐字输入ABC", interval: "5", fuctype: "1" });
|
||||
expect(out).toBe("逐字输入ABC");
|
||||
console.log("[键盘输入] 人工模拟逐字符 ✓:", out);
|
||||
}, 60000);
|
||||
|
||||
it("人工模拟模式含撇号与特殊字符(用户 09:05 踩坑场景)不抛语法错", async () => {
|
||||
const out = await invoke("Keyboard_Send_V1", { words: "hhhhh' +^%~(){}[]", interval: "10", fuctype: "1" });
|
||||
expect(out).toBe("hhhhh' +^%~(){}[]");
|
||||
console.log("[键盘输入] 人工模拟+撇号+特殊字符 ✓:", out);
|
||||
}, 60000);
|
||||
|
||||
it("人工模拟模式含中文标点(用户 09:32 踩坑场景:】【‘;。/ 智能引号)不抛语法错", async () => {
|
||||
const words = "jashfjksudfgisa54445哈哈哈哈AAAAHHBHG+t】【‘;。/";
|
||||
const out = await invoke("Keyboard_Send_V1", { words, interval: "10", fuctype: "1" });
|
||||
expect(out).toBe(words);
|
||||
console.log("[键盘输入] 人工模拟+中文标点+智能引号 ✓");
|
||||
}, 60000);
|
||||
|
||||
it("兼容性: Keyboard_Press 组合键语法(^s {ENTER} %{F4})不被文本转义破坏", async () => {
|
||||
// 组合键必须保留 SendKeys 原语法:^=Ctrl %=Alt {ENTER}=回车
|
||||
const out = await invoke("Keyboard_Press", { keys: "^s", interval: "10" });
|
||||
expect(out).toBe("^s");
|
||||
const out2 = await invoke("Keyboard_PressNew", { keys: "{ENTER}%{F4}" });
|
||||
expect(out2).toBe("{ENTER}%{F4}");
|
||||
console.log("[键盘输入] 组合键 SendKeys 语法保留 ✓");
|
||||
}, 60000);
|
||||
|
||||
it("兼容性: Keyboard_DriveSend_V2(键盘驱动输入,委托组件)按文本输入语义转义特殊字符", async () => {
|
||||
const out = await invoke("Keyboard_DriveSend_V2", { words: "驱动(a*100%)", interval: "5", keyboard_type: "1" });
|
||||
expect(out).toBe("驱动(a*100%)");
|
||||
console.log("[键盘输入] DriveSend 委托文本转义 ✓:", out);
|
||||
}, 60000);
|
||||
|
||||
it("兼容性: 基础版 Keyboard_Send interval 秒级换算", async () => {
|
||||
const out = await invoke("Keyboard_Send", { words: "秒", interval: "0.01" });
|
||||
expect(out).toBe("秒");
|
||||
console.log("[键盘输入] 基础版秒级间隔 ✓");
|
||||
}, 60000);
|
||||
|
||||
it("剪切板粘贴模式: 原文进入剪贴板并发送 Ctrl+V", async () => {
|
||||
const content = `粘贴${Date.now()}`;
|
||||
const out = await invoke("Keyboard_Send_V1", { words: content, fuctype: "2" });
|
||||
expect(out).toBe(content);
|
||||
const clip = await invoke("Clipboard_GetText", {}) as string;
|
||||
expect(clip).toBe(content);
|
||||
console.log("[键盘输入] 剪贴板粘贴模式 ✓ 剪贴板内容一致:", clip);
|
||||
}, 60000);
|
||||
|
||||
it("空输入仍报「键盘输入为空」", async () => {
|
||||
await expect(invoke("Keyboard_Send_V1", { words: "", interval: "10" })).rejects.toThrow("键盘输入为空");
|
||||
console.log("[键盘输入] 空输入校验 ✓");
|
||||
}, 60000);
|
||||
|
||||
it("输入目标窗口: 先激活记事本再输入, 前台窗口切到位", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const runPs = promisify(execFile);
|
||||
const ps = (cmd: string) => runPs("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", cmd], { windowsHide: true, timeout: 30000 });
|
||||
// 窗口标题随系统语言变化(英文 Notepad / 中文 记事本), 不硬编码, 启动后动态取实际标题
|
||||
await ps("Start-Process $env:SystemRoot\\System32\\notepad.exe; $diag=''; $ok=$false; foreach($i in 1..24){ $procs=Get-Process Notepad -ErrorAction SilentlyContinue; if($procs){ $p=$procs | Where-Object {$_.MainWindowTitle}; if($p){$ok=$true; break} } else { $diag='no-process' }; Start-Sleep -Milliseconds 500 }; if(-not $ok){ throw \"notepad not visible: $diag\" }");
|
||||
try {
|
||||
const titlePs = "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); (Get-Process Notepad | Where-Object { $_.MainWindowTitle } | Select-Object -First 1).MainWindowTitle";
|
||||
const title = (await ps(titlePs)).stdout.trim();
|
||||
expect(title.length).toBeGreaterThan(0);
|
||||
// GUI 焦点抢占受系统前台锁约束, 偶发失败属正常抖动, 有界重试
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
await invoke("Keyboard_Send_V1", { words: "目标窗口输入'测试", interval: "5", fuctype: "1", window_title: title });
|
||||
lastErr = undefined;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastErr = error;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
if (lastErr) throw lastErr;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
// activateWindowScript 内部已校验前台句柄=目标窗口, 这里再确认前台进程仍是记事本。
|
||||
// 不能用"标题包含"断言: Win11 记事本有会话恢复, 强杀后重开会还原未保存标签页,
|
||||
// 标题会变成 "*历史输入... - Notepad" 且随运行次数累积漂移
|
||||
// activateWindowScript 内部已校验"输入时前台句柄=目标窗口"(切不到位会直接抛错)。
|
||||
// 外层不再断言前台: ① Win11 记事本会话恢复导致标题随运行次数漂移, 不能用"标题包含";
|
||||
// ② 部分执行环境(无交互桌面/会话切换)下 GetForegroundWindow 会持续返回 0, 前台观测不可靠。
|
||||
// 这里只确认目标记事本窗口仍存在(输入动作确实发生在真实窗口上)
|
||||
const fgPs = "(Get-Process Notepad -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle } | Measure-Object).Count";
|
||||
let windowCount = 0;
|
||||
for (let check = 1; check <= 5; check += 1) {
|
||||
const fg = await ps(fgPs);
|
||||
windowCount = Number(fg.stdout.trim()) || 0;
|
||||
if (windowCount > 0) break;
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
}
|
||||
expect(windowCount).toBeGreaterThan(0);
|
||||
console.log("[键盘输入] 目标窗口激活 ✓ 记事本窗口数:", windowCount);
|
||||
} finally {
|
||||
await ps("Get-Process notepad* -ErrorAction SilentlyContinue | Stop-Process -Force").catch(() => {});
|
||||
}
|
||||
}, 90000);
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler, hasCapability } from "../engine/registry";
|
||||
import "./message";
|
||||
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
const ids = [
|
||||
"Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2",
|
||||
"Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2",
|
||||
"Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2"
|
||||
];
|
||||
|
||||
afterEach(async () => {});
|
||||
|
||||
describe("mail receive and reply", () => {
|
||||
it("registers find, reply and Outlook message capabilities", () => {
|
||||
for (const id of ids) expect(hasCapability(id), id).toBe(true);
|
||||
});
|
||||
|
||||
it("replies through SMTP with the selected mail sender", 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 (/^MAIL FROM/i.test(line)) socket.write("250 ok\r\n");
|
||||
else if (/^RCPT TO/i.test(line)) socket.write("250 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 port = (server.address() as net.AddressInfo).port;
|
||||
try {
|
||||
const handler = getHandler("Mail_ReplyMail_v2");
|
||||
expect(handler).toBeTruthy();
|
||||
const result = await handler!(context, node("Mail_ReplyMail_v2"), {
|
||||
account_obj: { host: "127.0.0.1", port, account: "robot@example.com", secure: "False" },
|
||||
mail_obj: { from: "owner@example.com", subject: "status", messageId: "<abc@example.com>" },
|
||||
content: "reply delivered"
|
||||
}, component);
|
||||
expect(result).toMatchObject({ accepted: ["owner@example.com"] });
|
||||
expect(received).toContain("reply delivered");
|
||||
expect(received).toContain("Re: status");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./window-extended";
|
||||
|
||||
const run = promisify(execFile);
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id: "smoke-mouse", componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
|
||||
const ctx = {
|
||||
runId: "mouse-smoke", flowId: "mouse-smoke-flow", flow: {} as RunContext["flow"],
|
||||
vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: "D:/office-test2",
|
||||
emit: () => {}, log: () => {}, componentOf: () => undefined
|
||||
} as unknown as RunContext;
|
||||
|
||||
const PS_API = `Add-Type @'
|
||||
using System; using System.Runtime.InteropServices;
|
||||
public static class RpaMouseSmoke {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
||||
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
|
||||
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr v);
|
||||
}
|
||||
'@; try { [void][RpaMouseSmoke]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}; $c = [RpaMouseSmoke+POINT]::new(); [void][RpaMouseSmoke]::GetCursorPos([ref]$c); "$($c.X),$($c.Y)"`;
|
||||
|
||||
async function cursorPos(): Promise<[number, number]> {
|
||||
const { stdout } = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PS_API], { windowsHide: true });
|
||||
return stdout.trim().split(",").map(Number) as [number, number];
|
||||
}
|
||||
|
||||
/** 共享桌面环境下光标可能被其他进程短暂挪动,轮询等待落位(容差 px),超时返回最后一次读数 */
|
||||
async function waitForCursor(target: [number, number], tolerance = 0, attempts = 10, gapMs = 100): Promise<[number, number]> {
|
||||
let pos = await cursorPos();
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
if (Math.abs(pos[0] - target[0]) <= tolerance && Math.abs(pos[1] - target[1]) <= tolerance) return pos;
|
||||
await new Promise((r) => setTimeout(r, gapMs));
|
||||
pos = await cursorPos();
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
async function invoke(componentId: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const handler = getHandler(componentId);
|
||||
expect(handler).toBeTypeOf("function");
|
||||
return handler!(ctx, node(componentId), args, component);
|
||||
}
|
||||
|
||||
let original: [number, number];
|
||||
afterEach(async () => {
|
||||
if (original) await invoke("Mouse_MoveToV2", { location: { x: original[0], y: original[1] }, position: "0", move_type: "2", speed: 100 });
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform !== "win32")("移动鼠标组件真实冒烟", () => {
|
||||
it("对象坐标 x/y JSON 不再报无效, 瞬间移动真实落位", async () => {
|
||||
original = await cursorPos();
|
||||
// 用户屏幕框选生成的浮点对象坐标(此前直接报「鼠标坐标无效」)
|
||||
const out = await invoke("Mouse_MoveToV2", { location: { x: 1397, y: 227.60000610351562 }, position: "0", move_type: "2", speed: 100 }) as { x: number; y: number };
|
||||
expect(out).toEqual({ x: 1397, y: 228 });
|
||||
const pos = await waitForCursor([1397, 228]);
|
||||
expect(pos[0]).toBe(1397);
|
||||
expect(pos[1]).toBe(228);
|
||||
console.log("[移动鼠标] 对象坐标解析 + 瞬间移动落位 ✓ 实际光标:", pos);
|
||||
}, 60000);
|
||||
|
||||
it("模拟人工移动(默认方式)平滑到达目标", async () => {
|
||||
original = await cursorPos();
|
||||
const out = await invoke("Mouse_MoveToV2", { location: "[300,300]", position: "0", move_type: "0", speed: 60 }) as { x: number; y: number };
|
||||
expect(out).toEqual({ x: 300, y: 300 });
|
||||
const pos = await waitForCursor([300, 300], 2);
|
||||
expect(Math.abs(pos[0] - 300)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(pos[1] - 300)).toBeLessThanOrEqual(2);
|
||||
console.log("[移动鼠标] 模拟人工移动 ✓ 实际光标:", pos);
|
||||
}, 60000);
|
||||
|
||||
it("匀速直线移动与字符串数组坐标", async () => {
|
||||
original = await cursorPos();
|
||||
const out = await invoke("Mouse_MoveToV2", { location: "[500,400]", position: "0", move_type: "1", speed: 80 }) as { x: number; y: number };
|
||||
expect(out).toEqual({ x: 500, y: 400 });
|
||||
const pos = await waitForCursor([500, 400]);
|
||||
expect(pos[0]).toBe(500);
|
||||
expect(pos[1]).toBe(400);
|
||||
console.log("[移动鼠标] 匀速直线 + 字符串坐标 ✓ 实际光标:", pos);
|
||||
}, 60000);
|
||||
|
||||
it("相对激活窗口左上角模式返回有效坐标", async () => {
|
||||
original = await cursorPos();
|
||||
const out = await invoke("Mouse_MoveToV2", { location: { x: 10, y: 10 }, position: "1", move_type: "2", speed: 100 }) as { x: number; y: number };
|
||||
expect(Number.isFinite(out.x)).toBe(true);
|
||||
expect(Number.isFinite(out.y)).toBe(true);
|
||||
console.log("[移动鼠标] 相对激活窗口模式 ✓ 目标:", out);
|
||||
}, 60000);
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./ocr-caps";
|
||||
import "./remaining-core";
|
||||
|
||||
const ctx = {
|
||||
signal: new AbortController().signal,
|
||||
log: () => {},
|
||||
vars: {},
|
||||
evidenceDir: "D:/office-test2/ocr-test"
|
||||
} as unknown as RunContext;
|
||||
|
||||
const node: FlowNode = {
|
||||
id: "smoke",
|
||||
componentId: "smoke",
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {},
|
||||
};
|
||||
const ocrFixtures = [
|
||||
"D:/office-test2/ocr-test/general.png",
|
||||
"D:/office-test2/ocr-test/code.png",
|
||||
"D:/office-test2/ocr-test/table.png",
|
||||
];
|
||||
const ocrFixturesReady = ocrFixtures.every((file) => existsSync(file));
|
||||
|
||||
async function runOcr(id: string, args: Record<string, unknown>) {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, `${id} 应已注册`).toBeTruthy();
|
||||
return handler!(ctx, node, args, {} as CatalogComponent);
|
||||
}
|
||||
|
||||
describe.skipIf(!ocrFixturesReady)("OCR 组件冒烟(本地离线识别,真实模型推理)", () => {
|
||||
it("OCR_GeneralTextV2 通用文本", async () => {
|
||||
const out = await runOcr("OCR_GeneralTextV2", { path: "D:/office-test2/ocr-test/general.png" });
|
||||
console.log("[GeneralTextV2]", JSON.stringify(out));
|
||||
expect(String(out)).toContain("张三");
|
||||
}, 300000);
|
||||
|
||||
it("OCR_KeyValueIdentification 键值对", async () => {
|
||||
const out = await runOcr("OCR_KeyValueIdentification", { image_path: "D:/office-test2/ocr-test/general.png" });
|
||||
console.log("[KeyValue]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OCR_AmountIdentification 金额", async () => {
|
||||
const out = await runOcr("OCR_AmountIdentification", { path: "D:/office-test2/ocr-test/general.png" });
|
||||
console.log("[Amount]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OCR_CalculationCode 算术验证码", async () => {
|
||||
const out = await runOcr("OCR_CalculationCode", { path: "D:/office-test2/ocr-test/code.png" });
|
||||
console.log("[CalcCode]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OCR_CommonEnglishNumbersCode 普通验证码", async () => {
|
||||
const out = await runOcr("OCR_CommonEnglishNumbersCode", { path: "D:/office-test2/ocr-test/code.png" });
|
||||
console.log("[CommonCode]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OCR_IdCardV2 卡证族代表", async () => {
|
||||
const out = await runOcr("OCR_IdCardV2", { path: "D:/office-test2/ocr-test/general.png" });
|
||||
console.log("[IdCardV2]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OCR_VatInvoiceV2 票据族代表", async () => {
|
||||
const out = await runOcr("OCR_VatInvoiceV2", { path: "D:/office-test2/ocr-test/table.png" });
|
||||
console.log("[VatInvoiceV2]", JSON.stringify(out));
|
||||
expect(String(out).length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
|
||||
it("OcrOffline_GetOcrTextNew 离线文本", async () => {
|
||||
const out = await runOcr("OcrOffline_GetOcrTextNew", { uiSelector: "D:/office-test2/ocr-test/general.png" });
|
||||
console.log("[OcrTextNew]", JSON.stringify(out));
|
||||
expect(String(out)).toContain("订单号");
|
||||
}, 300000);
|
||||
|
||||
it("OcrOffline_GetOcrTextPosition 离线定位", async () => {
|
||||
const out = await runOcr("OcrOffline_GetOcrTextPosition", { image_path: "D:/office-test2/ocr-test/general.png" });
|
||||
const words = out as Array<{ text: string; x: number; y: number }>;
|
||||
console.log("[OcrPosition]", JSON.stringify(words?.slice(0, 3)));
|
||||
expect(Array.isArray(words)).toBe(true);
|
||||
expect(words.length).toBeGreaterThan(0);
|
||||
expect(words[0]).toHaveProperty("x");
|
||||
}, 300000);
|
||||
|
||||
it("OcrOffline_GetOcrTable 离线表格", async () => {
|
||||
const out = await runOcr("OcrOffline_GetOcrTable", { image_path: "D:/office-test2/ocr-test/table.png", showTable: "1", isExcel: "0" });
|
||||
const rows = out as string[][];
|
||||
console.log("[OcrTable]", JSON.stringify(rows));
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
}, 300000);
|
||||
});
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { readFile, mkdtemp, 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 { Catalog, CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./office";
|
||||
import "./extended";
|
||||
import "./office-file-extended";
|
||||
import { EXCEL_COMMON_ALIASES } from "./office-aliases";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||
afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))));
|
||||
|
||||
describe("common Excel aliases", () => {
|
||||
it("registers only real catalog aliases", async () => {
|
||||
// vitest 工作目录为仓库根,包内测试统一以 cwd 定位仓库资源
|
||||
const catalog = JSON.parse(await readFile(path.resolve(process.cwd(), "resources/catalog.json"), "utf8")) as Catalog;
|
||||
const ids = new Set(catalog.components.map((item) => item.id));
|
||||
expect(EXCEL_COMMON_ALIASES.length).toBeGreaterThanOrEqual(25);
|
||||
for (const id of EXCEL_COMMON_ALIASES) { expect(ids.has(id), id).toBe(true); expect(getHandler(id), id).toBeTruthy(); }
|
||||
});
|
||||
|
||||
it("runs a real create, write and read pipeline through alias IDs", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-alias-")); dirs.push(directory);
|
||||
const handle = await run("Excel_NewExcel", { path: directory, name: "aliases.xlsx", override: "1" });
|
||||
await run("Excel_WriteCellNew", { workbook: handle, sheet: "Sheet1", row: 1, column: "A", content: "alias", save: "1" });
|
||||
await expect(run("Excel_ReadCellNew", { workbook: handle, sheet: "Sheet1", row: 1, column: "A" })).resolves.toBe("alias");
|
||||
});
|
||||
|
||||
it("accepts the Excel_NewExcel catalog keys (filePath / isMakedirs)", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-new-")); dirs.push(directory);
|
||||
const handle = await run("Excel_NewExcel", { filePath: directory, name: "catalog-keys.xlsx", isMakedirs: "True" }) as { path: string };
|
||||
expect(handle.path).toBe(path.resolve(directory, "catalog-keys.xlsx"));
|
||||
});
|
||||
|
||||
it("rejects a missing directory when isMakedirs is False", async () => {
|
||||
const directory = path.join(await mkdtemp(path.join(os.tmpdir(), "rpa-excel-new-")), "missing");
|
||||
dirs.push(directory);
|
||||
await expect(run("Excel_NewExcel", { filePath: directory, name: "none.xlsx", isMakedirs: "False" })).rejects.toThrow("目录不存在");
|
||||
});
|
||||
|
||||
it("opens a workbook through the Excel_OpenExcel filePath key", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-open-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "open-me.xlsx" }) as { path: string };
|
||||
await expect(run("Excel_OpenExcel", { filePath: created.path })).resolves.toMatchObject({ path: created.path });
|
||||
});
|
||||
|
||||
it("renames a sheet through the Excel_RenameSheet newSheet key", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-rename-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "rename-me.xlsx" }) as { path: string };
|
||||
await expect(run("Excel_RenameSheet", { objExcel: created, sheet: "Sheet1", newSheet: "库存表", isSave: "True" })).resolves.toBeTruthy();
|
||||
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||
await expect(run("Excel_GetSheetsName", { objExcel: reopened })).resolves.toEqual(["库存表"]);
|
||||
});
|
||||
|
||||
it("writes a range through the Excel_WriteRange catalog keys (objExcel/cellPos/data/isSave)", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-range-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "range.xlsx" }) as { path: string };
|
||||
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "B2", data: [[1, "壹"], [2, "贰"]], isSave: "True" });
|
||||
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "B2:C3" })).resolves.toEqual([[1, "壹"], [2, "贰"]]);
|
||||
});
|
||||
|
||||
it("saves as through the Excel_SaveAs catalog keys (filename/isAbsolutePath)", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-saveas-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "origin.xlsx" }) as { path: string };
|
||||
await run("Excel_WriteCell", { workbook: created, sheet: "Sheet1", row: 1, column: "A", content: "saveas" });
|
||||
const savedAs = await run("Excel_SaveAs", { objExcel: created, filename: path.join(directory, "legacy-saveas.xlsx"), isAbsolutePath: "True" }) as { path: string };
|
||||
expect(savedAs.path).toBe(path.resolve(directory, "legacy-saveas.xlsx"));
|
||||
const reopened = await run("Excel_OpenExcel", { filePath: savedAs.path }) as { id: string };
|
||||
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1" })).resolves.toEqual([["saveas"]]);
|
||||
});
|
||||
|
||||
it("resolves a relative Excel_SaveAs filename against the original workbook directory", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-saveas-rel-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "rel-origin.xlsx" }) as { path: string };
|
||||
const savedAs = await run("Excel_SaveAs", { objExcel: created, filename: "rel-target.xlsx", isAbsolutePath: "False" }) as { path: string };
|
||||
expect(savedAs.path).toBe(path.resolve(directory, "rel-target.xlsx"));
|
||||
});
|
||||
|
||||
it("combines the Excel_Cs_SaveAs directory path with the name field", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-saveas-cs-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "cs-origin.xlsx" }) as { path: string };
|
||||
const targetDir = path.join(directory, "nested");
|
||||
const savedAs = await run("Excel_Cs_SaveAs", { workbook: created, path: targetDir, name: "cs-target.xlsx" }) as { path: string };
|
||||
expect(savedAs.path).toBe(path.resolve(targetDir, "cs-target.xlsx"));
|
||||
const reopened = new ExcelJS.Workbook();
|
||||
await reopened.xlsx.readFile(savedAs.path);
|
||||
expect(reopened.worksheets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("clears a range through the Excel_ClearRange cellPos key", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-clear-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "clear.xlsx" }) as { path: string };
|
||||
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1", data: [["甲", "乙"], ["丙", "丁"]] });
|
||||
await run("Excel_ClearRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1:B2", isFormat: "False", isSave: "True" });
|
||||
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1:B2" })).resolves.toEqual([["", ""], ["", ""]]);
|
||||
});
|
||||
|
||||
it("clears through the Excel_Cs_ClearRange startRow/startColumn keys", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-clear-cs-")); dirs.push(directory);
|
||||
const created = await run("Excel_NewExcel", { filePath: directory, name: "clear-cs.xlsx" }) as { path: string };
|
||||
await run("Excel_WriteRange", { objExcel: created, sheet: "Sheet1", cellPos: "A1", data: [["1", "2"], ["3", "4"]] });
|
||||
await run("Excel_Cs_ClearRange", { objExcel: created, sheet: "Sheet1", startRow: "2", startColumn: "A", lastRow: "2", lastColumn: "B", mode: "内容", save: "True" });
|
||||
const reopened = await run("Excel_OpenExcel", { filePath: created.path }) as { id: string };
|
||||
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A2:B2" })).resolves.toEqual([["", ""]]);
|
||||
await expect(run("Excel_Cs_ReadContentV2", { workbook: reopened, sheet: "Sheet1", excelRng: "A1:B1" })).resolves.toEqual([["1", "2"]]);
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./office-file-extended";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (componentId: string): FlowNode => ({
|
||||
id: `office_file_extended_${componentId}`,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {}
|
||||
});
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-office-file-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 Office and file capabilities", () => {
|
||||
it("registers audited catalog component ids", async () => {
|
||||
const catalog = JSON.parse(await readFile(path.resolve("resources/catalog.json"), "utf8")) as { components: Array<{ id: string }> };
|
||||
const catalogIds = new Set(catalog.components.map((item) => item.id));
|
||||
for (const id of [
|
||||
"Excel_Cs_ReadRowCount", "Excel_Cs_ReadCellFormula", "Excel_Cs_GetPath", "Excel_GetPath",
|
||||
"Excel_Cs_ConvertColumn", "Excel_Cs_GetEmptyCell", "Excel_Cs_ReadColumn", "Excel_GetColDataNew",
|
||||
"Excel_Cs_DeleteRow", "Excel_Cs_DeleteColumn", "Excel_Cs_ClearRangeV4", "Excel_Cs_MergeCells",
|
||||
"Excel_Cs_SetHeight", "Excel_Cs_SetWidth", "Excel_Cs_CopyV2", "PDF_CreateNewPdf", "PDF_GetPdfInformation"
|
||||
]) {
|
||||
expect(catalogIds.has(id), `${id} should exist in catalog.json`).toBe(true);
|
||||
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads formulas, rows, columns, paths, and empty cells from a real workbook", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const filePath = path.join(directory, "read.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("Data");
|
||||
sheet.getCell("A1").value = "name";
|
||||
sheet.getCell("A2").value = "alpha";
|
||||
sheet.getCell("A3").value = "beta";
|
||||
sheet.getCell("B1").value = 2;
|
||||
sheet.getCell("B2").value = 3;
|
||||
sheet.getCell("B3").value = { formula: "SUM(B1:B2)", result: 5 };
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
|
||||
await expect(run("Excel_Cs_ReadRowCount", { workbook: filePath, sheet: "Data", range: "all" })).resolves.toBe(3);
|
||||
await expect(run("Excel_Cs_ReadCellFormula", { workbook: filePath, sheet: "Data", column: "B", row: 3 })).resolves.toBe("SUM(B1:B2)");
|
||||
await expect(run("Excel_Cs_GetEmptyCell", { workbook: filePath, sheet: "Data", column: "A", row: 1 })).resolves.toBe("A4");
|
||||
await expect(run("Excel_Cs_ReadColumn", { workbook: filePath, sheet: "Data", columnName: "A" })).resolves.toEqual(["name", "alpha", "beta"]);
|
||||
await expect(run("Excel_GetColDataNew", { objExcel: filePath, sheet: "Data", column: "B" })).resolves.toEqual([2, 3, 5]);
|
||||
await expect(run("Excel_Cs_GetPath", { workbook: { path: filePath } })).resolves.toBe(filePath);
|
||||
await expect(run("Excel_GetPath", { objExcel: filePath })).resolves.toBe(filePath);
|
||||
await expect(run("Excel_Cs_ConvertColumn", { convertInput: 28, convertType: "0" })).resolves.toBe("AB");
|
||||
await expect(run("Excel_Cs_ConvertColumn", { convertInput: "AB", convertType: "1" })).resolves.toBe(28);
|
||||
});
|
||||
|
||||
it("mutates rows, columns, ranges, dimensions, merged cells, and sheets on disk", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const filePath = path.join(directory, "mutate.xlsx");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("Data");
|
||||
sheet.addRows([["a", "b", "c"], [1, 2, 3], [4, 5, 6]]);
|
||||
await workbook.xlsx.writeFile(filePath);
|
||||
|
||||
await run("Excel_Cs_SetHeight", { workbook: filePath, sheet: "Data", rows: "1-2", height: 25, save: "1" });
|
||||
await run("Excel_Cs_SetWidth", { workbook: filePath, sheet: "Data", columns: "A,B", width: 18, save: "1" });
|
||||
await run("Excel_Cs_ClearRangeV4", { workbook: filePath, sheet: "Data", excelRng: "B2:B2", save: "1" });
|
||||
await run("Excel_Cs_MergeCells", { workbook: filePath, sheet: "Data", range: "A4:B4", merge: "0", save: "1" });
|
||||
await run("Excel_Cs_CopyV2", { workbook: filePath, sheet: "Data", newSheet: "Copy", save: "1" });
|
||||
await run("Excel_Cs_DeleteRow", { workbook: filePath, sheet: "Copy", row: 2, mode: "single", save: "1" });
|
||||
await run("Excel_Cs_DeleteColumn", { workbook: filePath, sheet: "Copy", column: "C", mode: "single", save: "1" });
|
||||
|
||||
const saved = new ExcelJS.Workbook();
|
||||
await saved.xlsx.readFile(filePath);
|
||||
const data = saved.getWorksheet("Data")!;
|
||||
const copy = saved.getWorksheet("Copy")!;
|
||||
expect(data.getRow(1).height).toBe(25);
|
||||
expect(data.getColumn("A").width).toBe(18);
|
||||
expect(data.getCell("B2").value).toBeNull();
|
||||
expect(data.getCell("A4").isMerged).toBe(true);
|
||||
expect(copy.getCell("A2").value).toBe(4);
|
||||
expect(copy.getCell("C1").value).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts selected pages and returns metadata from real PDF files", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const sourcePath = path.join(directory, "source.pdf");
|
||||
const document = await PDFDocument.create();
|
||||
document.setTitle("RPA Test");
|
||||
document.setAuthor("Codex");
|
||||
document.addPage([100, 100]);
|
||||
document.addPage([200, 200]);
|
||||
document.addPage([300, 300]);
|
||||
await writeFile(sourcePath, await document.save());
|
||||
|
||||
const output = await run("PDF_CreateNewPdf", { fileInPath: sourcePath, pdfRange: "pages", pdfNum: "1,3", newPdfPath: directory }) as string;
|
||||
const extracted = await PDFDocument.load(await readFile(output));
|
||||
expect(extracted.getPageCount()).toBe(2);
|
||||
expect(extracted.getPage(0).getWidth()).toBe(100);
|
||||
expect(extracted.getPage(1).getWidth()).toBe(300);
|
||||
await expect(run("PDF_GetPdfInformation", { fileInPath: sourcePath })).resolves.toMatchObject({
|
||||
path: sourcePath, fileName: "source.pdf", pageCount: 3, title: "RPA Test", author: "Codex"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { mkdir, 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 { PDFDocument, StandardFonts } from "pdf-lib";
|
||||
import { PNG } from "pngjs";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./pdf";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown>) {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, id).toBeTruthy();
|
||||
return handler!(context, node(id), args, component);
|
||||
}
|
||||
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
|
||||
|
||||
describe("PDF high-level capabilities", () => {
|
||||
it("creates a pdf fixture", async () => {
|
||||
const directory = await mkdtempSafe();
|
||||
const pdf = await PDFDocument.create();
|
||||
const page = pdf.addPage([220, 120]);
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const png = new PNG({ width: 8, height: 8 });
|
||||
png.data.fill(255); png.data[0] = 20; png.data[1] = 90; png.data[2] = 200; png.data[3] = 255;
|
||||
page.drawImage(await pdf.embedPng(PNG.sync.write(png)), { x: 140, y: 70, width: 40, height: 40 });
|
||||
for (const [value, x, y] of [["Name", 24, 90], ["Age", 120, 90], ["Alice", 24, 72], ["30", 120, 72]] as Array<[string, number, number]>) page.drawText(value, { x, y, size: 9, font });
|
||||
await writeFile(path.join(directory, "source.pdf"), await pdf.save());
|
||||
const filePath = path.join(directory, "source.pdf");
|
||||
expect((await readFile(path.join(directory, "source.pdf"))).byteLength).toBeGreaterThan(0);
|
||||
const images = await run("PDF_GetPdfImg", { fileInPath: filePath, pdfRange: "all", pdfNum: "1", imgSavePath: directory, imageType: "png" }) as string[];
|
||||
expect(images.length).toBeGreaterThan(0);
|
||||
expect((await readFile(images[0])).subarray(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
|
||||
await expect(run("PDF_GetPdfTable", { fileInPath: filePath, pdfRange: "all", pdfNum: "1" })).resolves.toEqual([["Name", "Age"], ["Alice", "30"]]);
|
||||
|
||||
const encryptedPath = path.join(directory, "encrypted.pdf");
|
||||
await writeFile(encryptedPath, await readFile(filePath));
|
||||
await expect(run("PDF_PdfEncryption", { fileInPath: encryptedPath, passWord: "secret" })).resolves.toBe(encryptedPath);
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const opened = await pdfjs.getDocument({ data: new Uint8Array(await readFile(encryptedPath)), password: "secret" }).promise;
|
||||
expect(opened.numPages).toBe(1);
|
||||
}, 60000);
|
||||
});
|
||||
async function mkdtempSafe(): Promise<string> {
|
||||
const directory = await (await import("node:fs/promises")).mkdtemp(path.join(os.tmpdir(), "rpa-pdf-high-"));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { capabilityCount, getCapabilityMeta } from "../engine/registry";
|
||||
import {
|
||||
PLATFORM_GROUPS,
|
||||
buildDomainPlatformMap,
|
||||
capabilityDomains
|
||||
} from "./register-all";
|
||||
|
||||
describe("PLATFORM_GROUPS 平台单一源(迁移指南五件小事 #1)", () => {
|
||||
it("每个能力域都有平台行,不允许缺行", () => {
|
||||
for (const domain of capabilityDomains) {
|
||||
expect(PLATFORM_GROUPS[domain], `域 ${domain} 缺少 PLATFORM_GROUPS 行`).toBeDefined();
|
||||
}
|
||||
expect(Object.keys(PLATFORM_GROUPS).sort()).toEqual([...capabilityDomains].sort());
|
||||
});
|
||||
|
||||
it("现阶段数据只允许 win32,darwin/linux 仅类型占位不填数据", () => {
|
||||
for (const [domain, platforms] of Object.entries(PLATFORM_GROUPS)) {
|
||||
expect(
|
||||
platforms.every((p) => p === "win32"),
|
||||
`域 ${domain} 填了 win32 以外的平台数据`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("registerAll 后所有能力 meta.platforms 与分组表一致(运行时派生)", async () => {
|
||||
// buildDomainPlatformMap 逐域加载(动态 import 有缓存,必须在 registerAll 语义内一次完成)
|
||||
const map = await buildDomainPlatformMap();
|
||||
expect(capabilityCount()).toBeGreaterThan(700);
|
||||
expect(map.size).toBe(capabilityCount());
|
||||
for (const id of map.keys()) {
|
||||
const meta = getCapabilityMeta(id);
|
||||
expect(meta, `能力 ${id} 无 meta`).toBeTruthy();
|
||||
expect(meta!.platforms, `能力 ${id} 的 meta.platforms 未从分组表派生`).toEqual(["win32"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEngine } from "../engine/engine";
|
||||
import { capabilityCount, capabilityIds, getCapabilityMeta } from "../engine/registry";
|
||||
import { isKnownErrorCode } from "../engine/error-codes";
|
||||
import { capabilityDomains, registerAll } from "./register-all";
|
||||
import "./index";
|
||||
|
||||
describe("registerAll 显式注册入口", () => {
|
||||
it("副作用入口已注册全量包内能力,registerAll 全量幂等", async () => {
|
||||
// 包内 capabilities/index 注册 719 个 id(909 快照 = 包内 + 宿主浏览器能力)
|
||||
const before = capabilityCount();
|
||||
expect(before).toBeGreaterThan(700);
|
||||
const engine = await registerAll(createEngine());
|
||||
expect(engine).toBeDefined();
|
||||
expect(capabilityCount()).toBe(before);
|
||||
});
|
||||
|
||||
it("按子集注册:只拉起指定域,未知域报错", async () => {
|
||||
expect(capabilityDomains).toContain("excelCom");
|
||||
await expect(registerAll(createEngine(), ["excelCom", "no-such-domain"])).rejects.toThrow("未知能力域");
|
||||
await registerAll(createEngine(), ["files"]);
|
||||
expect(getCapabilityMeta).toBeDefined();
|
||||
});
|
||||
|
||||
it("meta(任务 4.1):老能力允许缺省;已声明 meta 的能力错误码必须在稳定清单内", () => {
|
||||
// 全量注册后的每个 meta,其 errorCodes 都必须落在 RUNTIME_ERROR_CODES 清单内
|
||||
for (const id of capabilityIds()) {
|
||||
const meta = getCapabilityMeta(id);
|
||||
if (!meta) continue;
|
||||
for (const code of meta.errorCodes ?? []) {
|
||||
expect(isKnownErrorCode(code), `${id} 的错误码 ${code} 未登记 error-codes.ts`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./vision";
|
||||
import "./remaining-core";
|
||||
|
||||
const ctx = {
|
||||
signal: new AbortController().signal,
|
||||
log: () => {},
|
||||
vars: {},
|
||||
evidenceDir: "D:/office-test2/ocr-test"
|
||||
} as unknown as RunContext;
|
||||
|
||||
const node = { id: "smoke-rp", componentId: "Image_RelativePosition", position: { x: 0, y: 0 }, values: {}, options: {} };
|
||||
const relativePositionFixtures = [
|
||||
"D:/office-test2/ocr-test/general.png",
|
||||
"D:/office-test2/ocr-test/order-template.png",
|
||||
];
|
||||
const relativePositionFixturesReady = relativePositionFixtures.every((file) => existsSync(file));
|
||||
|
||||
describe.skipIf(!relativePositionFixturesReady)("对比图像相对位置冒烟", () => {
|
||||
it("小图在大图中命中并返回坐标", async () => {
|
||||
const handler = getHandler("Image_RelativePosition");
|
||||
expect(handler).toBeTruthy();
|
||||
const out = await handler!(
|
||||
ctx,
|
||||
node,
|
||||
{
|
||||
source: "D:/office-test2/ocr-test/general.png",
|
||||
template: "D:/office-test2/ocr-test/order-template.png"
|
||||
},
|
||||
{} as CatalogComponent
|
||||
);
|
||||
console.log("[RelativePosition 正向]", JSON.stringify(out));
|
||||
const hit = out as { x: number; y: number; width: number; height: number; score: number };
|
||||
expect(hit.x).toBeGreaterThanOrEqual(0);
|
||||
expect(hit.y).toBeGreaterThan(0);
|
||||
expect(hit.score).toBeGreaterThan(0.9);
|
||||
}, 120000);
|
||||
|
||||
it("填反(大图填进查找目标)自动互换后仍命中", async () => {
|
||||
const logs: string[] = [];
|
||||
const ctxWithLog = { ...ctx, log: (m: string) => logs.push(m) } as unknown as RunContext;
|
||||
const handler = getHandler("Image_RelativePosition")!;
|
||||
const out = await handler!(
|
||||
ctxWithLog,
|
||||
node,
|
||||
{
|
||||
source: "D:/office-test2/ocr-test/order-template.png",
|
||||
template: "D:/office-test2/ocr-test/general.png"
|
||||
},
|
||||
{} as CatalogComponent
|
||||
);
|
||||
console.log("[RelativePosition 互换]", JSON.stringify(out), "logs:", logs.join(" | "));
|
||||
expect(out).toBeTruthy();
|
||||
}, 120000);
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./system-extended";
|
||||
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const children: Array<ReturnType<typeof spawn>> = [];
|
||||
const node = (componentId: string): FlowNode => ({ id: componentId, componentId, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown> = {}) {
|
||||
const handler = getHandler(id);
|
||||
expect(handler, id).toBeTruthy();
|
||||
return handler!(context, node(id), args, component);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const child of children.splice(0)) if (!child.killed) child.kill();
|
||||
delete process.env.RPA_STUDIO_TEST_ENV;
|
||||
});
|
||||
|
||||
describe("system extended capabilities", () => {
|
||||
it("reads and writes process environment values and standard paths", async () => {
|
||||
await expect(run("System_SetEnv", { name: "RPA_STUDIO_TEST_ENV", value: "connected" })).resolves.toBe("connected");
|
||||
await expect(run("System_GetEnv", { name: "RPA_STUDIO_TEST_ENV" })).resolves.toBe("connected");
|
||||
await expect(run("System_GetTempPath")).resolves.toBe(os.tmpdir());
|
||||
await expect(run("System_GetHomePath")).resolves.toBe(os.homedir());
|
||||
await expect(run("System_GetSystemPath", { path_name: "documents" })).resolves.toBe(path.join(os.homedir(), "Documents"));
|
||||
});
|
||||
|
||||
it("finds and terminates a test child process by PID", async () => {
|
||||
const child = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { windowsHide: true });
|
||||
children.push(child);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const pids = await run("App_GetPids", { pattern: "node.exe" }) as number[];
|
||||
expect(pids).toContain(child.pid);
|
||||
const exited = new Promise<number | null>((resolve) => child.once("exit", resolve));
|
||||
await run("App_Kill", { pid: child.pid });
|
||||
const exitCode = await exited;
|
||||
expect(exitCode).not.toBeUndefined();
|
||||
}, 10000);
|
||||
|
||||
it("registers safe clipboard and window variants", () => {
|
||||
for (const id of ["Clipboard_GetTextV1", "Clipboard_SetTextV1", "Clipboard_ClearClipboard", "Window_GetWindowRect", "Window_SetWindowState", "Window_SetWindowState_v1", "Window_SizeWindow_v1"]) {
|
||||
expect(getHandler(id), id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
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 "../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<string, unknown> = {}, outputVar?: string): Promise<unknown> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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]]);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PNG } from "pngjs";
|
||||
import { clickPoint, clampRegion, cropPng, parseRegion } from "./vision";
|
||||
|
||||
function makeImage(width = 100, height = 80): PNG {
|
||||
const png = new PNG({ width, height });
|
||||
for (let i = 0; i < png.data.length; i += 4) {
|
||||
png.data[i] = 200;
|
||||
png.data[i + 1] = 100;
|
||||
png.data[i + 2] = 50;
|
||||
png.data[i + 3] = 255;
|
||||
}
|
||||
return png;
|
||||
}
|
||||
|
||||
describe("parseRegion", () => {
|
||||
it("accepts object x/y/width/height", () => {
|
||||
const image = makeImage();
|
||||
expect(parseRegion('{"x":10,"y":20,"width":30,"height":40}', image)).toEqual({ x: 10, y: 20, width: 30, height: 40 });
|
||||
});
|
||||
|
||||
it("accepts array [x,y,width,height]", () => {
|
||||
const image = makeImage();
|
||||
expect(parseRegion("[1,2,3,4]", image)).toEqual({ x: 1, y: 2, width: 3, height: 4 });
|
||||
});
|
||||
|
||||
it("accepts left/top aliases and corner points", () => {
|
||||
const image = makeImage();
|
||||
expect(parseRegion('{"left":5,"top":6,"width":7,"height":8}', image)).toEqual({ x: 5, y: 6, width: 7, height: 8 });
|
||||
expect(parseRegion('{"x":5,"y":6,"x2":15,"y2":16}', image)).toEqual({ x: 5, y: 6, width: 10, height: 10 });
|
||||
});
|
||||
|
||||
it("rejects malformed JSON and unknown shapes", () => {
|
||||
const image = makeImage();
|
||||
expect(() => parseRegion("not-json", image)).toThrow(/无法解析/);
|
||||
expect(() => parseRegion('{"foo":1}', image)).toThrow(/不符合要求/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampRegion", () => {
|
||||
it("clamps region into image bounds", () => {
|
||||
const image = makeImage(100, 80);
|
||||
expect(clampRegion({ x: -5, y: -5, width: 200, height: 200 }, image)).toEqual({ x: 0, y: 0, width: 100, height: 80 });
|
||||
expect(clampRegion({ x: 90, y: 70, width: 50, height: 50 }, image)).toEqual({ x: 90, y: 70, width: 10, height: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cropPng", () => {
|
||||
it("crops sub-image with expected size and pixel origin", () => {
|
||||
const image = makeImage();
|
||||
const cropped = cropPng(image, { x: 10, y: 20, width: 30, height: 40 });
|
||||
expect(cropped.width).toBe(30);
|
||||
expect(cropped.height).toBe(40);
|
||||
const index = (0 * 30 + 0) * 4;
|
||||
expect([cropped.data[index], cropped.data[index + 1], cropped.data[index + 2]]).toEqual([200, 100, 50]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clickPoint", () => {
|
||||
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
|
||||
|
||||
it("defaults to center", () => {
|
||||
expect(clickPoint(region, "")).toEqual({ x: 130, y: 220 });
|
||||
expect(clickPoint(region, "center")).toEqual({ x: 130, y: 220 });
|
||||
});
|
||||
|
||||
it("supports nine-grid anchors", () => {
|
||||
expect(clickPoint(region, "leftTop")).toEqual({ x: 100, y: 200 });
|
||||
expect(clickPoint(region, "rightBottom")).toEqual({ x: 160, y: 240 });
|
||||
expect(clickPoint(region, "top")).toEqual({ x: 130, y: 200 });
|
||||
expect(clickPoint(region, "left")).toEqual({ x: 100, y: 220 });
|
||||
});
|
||||
|
||||
it("supports custom offset JSON", () => {
|
||||
expect(clickPoint(region, '{"x":5,"y":6}')).toEqual({ x: 105, y: 206 });
|
||||
});
|
||||
|
||||
it("falls back to center for unknown values", () => {
|
||||
expect(clickPoint(region, "bogus")).toEqual({ x: 130, y: 220 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy position aliases", () => {
|
||||
it("accepts kebab-case anchors saved by older catalogs", () => {
|
||||
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
|
||||
expect(clickPoint(region, "left-top")).toEqual({ x: 100, y: 200 });
|
||||
expect(clickPoint(region, "right-top")).toEqual({ x: 160, y: 200 });
|
||||
expect(clickPoint(region, "left-bottom")).toEqual({ x: 100, y: 240 });
|
||||
expect(clickPoint(region, "right-bottom")).toEqual({ x: 160, y: 240 });
|
||||
});
|
||||
|
||||
it("still falls back to center for unknown anchors", () => {
|
||||
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
|
||||
expect(clickPoint(region, "middle")).toEqual({ x: 130, y: 220 });
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PNG } from "pngjs";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { findImage } from "./vision";
|
||||
|
||||
function makeScreen(width: number, height: number, patch: { x: number; y: number; w: number; h: number }): PNG {
|
||||
const png = new PNG({ width, height });
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
const inPatch = x >= patch.x && x < patch.x + patch.w && y >= patch.y && y < patch.y + patch.h;
|
||||
// 背景: 平滑斜向渐变(贴近真实界面); 补丁: 纯红色
|
||||
png.data[i] = inPatch ? 220 : (x + y) % 90;
|
||||
png.data[i + 1] = inPatch ? 30 : (x + y + 30) % 90;
|
||||
png.data[i + 2] = inPatch ? 40 : (x + y + 60) % 90;
|
||||
png.data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return png;
|
||||
}
|
||||
|
||||
function crop(source: PNG, x: number, y: number, w: number, h: number): PNG {
|
||||
const out = new PNG({ width: w, height: h });
|
||||
PNG.bitblt(source, out, x, y, w, h, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("findImage 匹配质量", () => {
|
||||
it("相似度0.9时精确命中模板位置且不误报", async () => {
|
||||
const patch = { x: 123, y: 77, w: 48, h: 36 };
|
||||
const screen = makeScreen(400, 300, patch);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vision-"));
|
||||
const screenPath = path.join(dir, "screen.png");
|
||||
const templatePath = path.join(dir, "template.png");
|
||||
fs.writeFileSync(screenPath, PNG.sync.write(screen));
|
||||
fs.writeFileSync(templatePath, PNG.sync.write(crop(screen, patch.x, patch.y, patch.w, patch.h)));
|
||||
// 相似度 0.9 → 最大差异 0.1
|
||||
const result = await findImage(templatePath, screenPath, 0.1);
|
||||
expect(result).not.toBeNull();
|
||||
// 采样步长落在整数边界时, 相邻1px偏移可能同样满分, 允许±1px
|
||||
expect(Math.abs(result!.x - patch.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(result!.y - patch.y)).toBeLessThanOrEqual(1);
|
||||
expect(result!.score).toBeGreaterThanOrEqual(0.9);
|
||||
// 无该补丁的屏幕应返回 null 而非乱命中
|
||||
const other = makeScreen(400, 300, { x: 0, y: 0, w: 1, h: 1 });
|
||||
const otherPath = path.join(dir, "other.png");
|
||||
fs.writeFileSync(otherPath, PNG.sync.write(other));
|
||||
const miss = await findImage(templatePath, otherPath, 0.1);
|
||||
expect(miss).toBeNull();
|
||||
});
|
||||
|
||||
it("模板被缩放(1.25倍)时按尺度兜底命中", async () => {
|
||||
const patch = { x: 200, y: 120, w: 60, h: 40 };
|
||||
const screen = makeScreen(500, 350, patch);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vision-"));
|
||||
const screenPath = path.join(dir, "screen.png");
|
||||
fs.writeFileSync(screenPath, PNG.sync.write(screen));
|
||||
// 模板取自 DIP 截图(比物理屏小1.25倍): 把补丁缩小1/1.25后存为模板
|
||||
const { createCanvas, loadImage } = await import("@napi-rs/canvas");
|
||||
const small = crop(screen, patch.x, patch.y, patch.w, patch.h);
|
||||
const srcCanvas = createCanvas(small.width, small.height);
|
||||
const srcCtx = srcCanvas.getContext("2d");
|
||||
const imgData = srcCtx.createImageData(small.width, small.height);
|
||||
imgData.data.set(small.data);
|
||||
srcCtx.putImageData(imgData, 0, 0);
|
||||
const dstCanvas = createCanvas(Math.round(small.width / 1.25), Math.round(small.height / 1.25));
|
||||
const dstCtx = dstCanvas.getContext("2d");
|
||||
dstCtx.drawImage(srcCanvas, 0, 0, dstCanvas.width, dstCanvas.height);
|
||||
// canvas → pngjs PNG(PNG.sync.write 只认 pngjs 对象)
|
||||
const scaledData = dstCtx.getImageData(0, 0, dstCanvas.width, dstCanvas.height);
|
||||
const templatePng = new PNG({ width: dstCanvas.width, height: dstCanvas.height });
|
||||
templatePng.data = Buffer.from(scaledData.data);
|
||||
const templatePath = path.join(dir, "template-small.png");
|
||||
fs.writeFileSync(templatePath, PNG.sync.write(templatePng));
|
||||
const result = await findImage(templatePath, screenPath, 0.1, [1.25, 1 / 1.25]);
|
||||
expect(result).not.toBeNull();
|
||||
expect(Math.abs(result!.x - patch.x)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(result!.y - patch.y)).toBeLessThanOrEqual(3);
|
||||
void loadImage;
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./window-extended";
|
||||
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
const ids = [
|
||||
"Window_ActiveWindow_v1", "Window_Get", "Window_GetWindow", "Window_GetWindow_v1", "Window_GetWindowMessage",
|
||||
"Window_GetWindowMessage_v1", "Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow",
|
||||
"Mouse_MoveToV1", "Mouse_MoveToV2", "Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew",
|
||||
"Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2", "Mouse_ClickCombine_v3", "Mouse_ClickCombine_v4"
|
||||
];
|
||||
|
||||
describe("window extended capabilities", () => {
|
||||
it("registers audited window and mouse variants", () => {
|
||||
for (const id of ids) expect(getHandler(id), id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects missing window selectors before touching the desktop", async () => {
|
||||
for (const id of ["Window_GetWindow", "Window_ActiveWindow_v1", "Window_MoveWindow", "Window_SizeWindow"]) {
|
||||
await expect(getHandler(id)!(context, node(id), {}, component)).rejects.toMatchObject({ code: "validation_error" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed mouse coordinates before injecting input", async () => {
|
||||
await expect(getHandler("Mouse_MoveToV1")!(context, node("Mouse_MoveToV1"), { location: "bad" }, component)).rejects.toMatchObject({ code: "validation_error" });
|
||||
await expect(getHandler("Mouse_DragToV1")!(context, node("Mouse_DragToV1"), { Start: "bad", End: "[1,2]" }, component)).rejects.toMatchObject({ code: "validation_error" });
|
||||
await expect(getHandler("Mouse_ClickCombine")!(context, node("Mouse_ClickCombine"), { target: "1", location: "[1,2]" }, component)).rejects.toMatchObject({ code: "validation_error" });
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
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 { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./word";
|
||||
import "./word-extended";
|
||||
|
||||
const directories: string[] = [];
|
||||
const context = {} as RunContext;
|
||||
const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
|
||||
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
|
||||
|
||||
const ids = ["Word_New_V1", "Word_GetPath", "Word_ReadAll", "Word_Replace", "Word_Enter", "Word_InsertPage", "Word_SetFontName", "Word_SetFontSize", "Word_SetFontStyle", "Word_SetFontColor", "Word_SetAlign", "Word_InsertPicture", "Word_WriteTable", "Word_Close", "Word_ReadSelect", "Word_CutSelect", "Word_CopySelect", "Word_PasteSelect", "Word_DeleteSelect", "Word_SelectAll", "Word_SelectRow", "Word_SetPosition", "Word_MovePosition", "Word_PositionToSelect", "Word_ReadTable"];
|
||||
|
||||
describe("Word extended capabilities", () => {
|
||||
it("registers automated Word operations", () => { for (const id of ids) expect(getHandler(id), id).toBeTruthy(); });
|
||||
|
||||
it("creates, writes, reads and replaces Word content", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-extended-")); directories.push(directory);
|
||||
const handle = await run("Word_New_V1", { filePath: directory, name: "extended.docx" });
|
||||
await run("Word_Write", { objWord: handle, text: "原始内容" });
|
||||
await expect(run("Word_ReadAll", { objWord: handle })).resolves.toContain("原始内容");
|
||||
await run("Word_Replace", { objWord: handle, oldText: "原始", newText: "替换后" });
|
||||
await expect(run("Word_ReadAll", { objWord: handle })).resolves.toContain("替换后内容");
|
||||
await run("Word_SetFontSize", { objWord: handle, fontSize: 12 });
|
||||
await run("Word_SetFontStyle", { objWord: handle, isBold: "True", isItalic: "False", isUnderline: "False" });
|
||||
await run("Word_Enter", { objWord: handle });
|
||||
expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000);
|
||||
}, 120000);
|
||||
|
||||
it("uses a persistent selection and reads a Word table through COM", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-selection-")); directories.push(directory);
|
||||
let handle = await run("Word_New_V1", { filePath: directory, name: "selection.docx" });
|
||||
await run("Word_Write", { objWord: handle, text: "alpha beta gamma" });
|
||||
handle = await run("Word_PositionToSelect", { objWord: handle, text: "beta", pos_index: 1, direction: "select" });
|
||||
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toBe("beta");
|
||||
handle = await run("Word_CopySelect", { objWord: handle });
|
||||
handle = await run("Word_MovePosition", { objWord: handle, length: 1, unit: "word", direction: "right", isSelect: false });
|
||||
await run("Word_PasteSelect", { objWord: handle });
|
||||
handle = await run("Word_PositionToSelect", { objWord: handle, text: "gamma", direction: "select" });
|
||||
handle = await run("Word_CutSelect", { objWord: handle });
|
||||
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toBe("");
|
||||
await run("Word_DeleteSelect", { objWord: handle });
|
||||
handle = await run("Word_SetPosition", { objWord: handle, length: -2, unit: "word" });
|
||||
handle = await run("Word_SelectRow", { objWord: handle, st: 1, ed: 1 });
|
||||
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toContain("alpha");
|
||||
handle = await run("Word_SelectAll", { objWord: handle });
|
||||
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toContain("alpha beta beta");
|
||||
await run("Word_WriteTable", { objWord: handle, data: [["name", "age"], ["Alice", "30"]] });
|
||||
await expect(run("Word_ReadTable", { objWord: handle, find_type: "content", table_content: "Alice" })).resolves.toEqual([["name", "age"], ["Alice", "30"]]);
|
||||
}, 180000);
|
||||
|
||||
it("saves and saves-as a Word document through real COM", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-saveas-")); directories.push(directory);
|
||||
const handle = await run("Word_New_V1", { filePath: directory, name: "source.docx" });
|
||||
await run("Word_Write", { objWord: handle, text: "另存前内容" });
|
||||
|
||||
await expect(run("Word_Save", { objWord: handle })).resolves.toEqual({ path: path.join(directory, "source.docx") });
|
||||
await expect(run("Word_Save", { objWord: { path: path.join(directory, "missing.docx") } })).rejects.toThrow("Word文件不存在");
|
||||
|
||||
const targetDir = path.join(directory, "out");
|
||||
const saved = await run("Word_SaveAs", { objWord: handle, fileAddr: targetDir, filename: "renamed" }) as { path: string };
|
||||
expect(saved.path).toBe(path.join(targetDir, "renamed.docx"));
|
||||
await expect(readFile(saved.path)).resolves.toBeTruthy();
|
||||
|
||||
const kept = await run("Word_SaveAs", { objWord: saved, fileAddr: directory }) as { path: string };
|
||||
expect(kept.path).toBe(path.join(directory, "renamed.docx"));
|
||||
await expect(readFile(kept.path)).resolves.toBeTruthy();
|
||||
|
||||
const relative = await run("Word_SaveAs", { objWord: kept, fileAddr: "sub", filename: "rel-target" }) as { path: string };
|
||||
expect(relative.path).toBe(path.join(directory, "sub", "rel-target.docx"));
|
||||
await expect(readFile(relative.path)).resolves.toBeTruthy();
|
||||
}, 180000);
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowDefinition, FlowNode, RunEvent } from "../types";
|
||||
import { register } from "./registry";
|
||||
import { NODE_STATUS_TO_STEP_STATUS, toStepStatus } from "./types";
|
||||
import { runFlow } from "./runtime";
|
||||
|
||||
type EmittedEvent = Omit<RunEvent, "runId">;
|
||||
type RunFlowOptions = Parameters<typeof runFlow>[1];
|
||||
|
||||
function makeFlow(partial: Partial<FlowDefinition> & { nodes: FlowNode[] }): FlowDefinition {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "flow_checkpoint",
|
||||
name: "检查点测试流程",
|
||||
variables: [],
|
||||
nodes: partial.nodes,
|
||||
edges: partial.edges || [],
|
||||
meta: { createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", appVersion: "test" }
|
||||
};
|
||||
}
|
||||
|
||||
function node(id: string, componentId: string): FlowNode {
|
||||
return { id, componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
|
||||
}
|
||||
|
||||
const fakeComponent = (id: string): CatalogComponent =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
tags: [],
|
||||
platformTag: null,
|
||||
icon: "",
|
||||
risk: "low",
|
||||
fields: [],
|
||||
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
|
||||
slots: [],
|
||||
functionCalls: []
|
||||
}) as unknown as CatalogComponent;
|
||||
|
||||
const baseOptions = (emit: (event: EmittedEvent) => void): RunFlowOptions => ({
|
||||
runId: "run_cp",
|
||||
evidenceDir: ".",
|
||||
signal: new AbortController().signal,
|
||||
emit,
|
||||
log: () => {},
|
||||
componentOf: (n: FlowNode) => fakeComponent(n.componentId),
|
||||
variables: {}
|
||||
});
|
||||
|
||||
register("Cp_Idempotent", async () => "ok", { idempotent: true });
|
||||
register("Cp_NonIdempotent", async () => "ok", { idempotent: false });
|
||||
register("Cp_DefaultMeta", async () => "ok");
|
||||
|
||||
describe("状态映射(NodeState ↔ 对面 StepStatus)", () => {
|
||||
it("8 个状态全分支映射,唯一非同名是 idle→pending", () => {
|
||||
expect(toStepStatus("idle")).toBe("pending");
|
||||
for (const status of ["running", "waiting_user", "succeeded", "failed", "cancelled", "unknown", "skipped"] as const) {
|
||||
expect(toStepStatus(status)).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
it("映射表为全量双射(无遗漏键)", () => {
|
||||
expect(Object.keys(NODE_STATUS_TO_STEP_STATUS).sort()).toEqual(
|
||||
["cancelled", "failed", "idle", "running", "skipped", "succeeded", "unknown", "waiting_user"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("A++ 检查点事件负载", () => {
|
||||
it("node.finished 携带 checkpointId/resumable/幂等键,非幂等不带键", async () => {
|
||||
const events: EmittedEvent[] = [];
|
||||
const flow = makeFlow({
|
||||
nodes: [node("first", "Cp_Idempotent"), node("second", "Cp_NonIdempotent"), node("third", "Cp_DefaultMeta")],
|
||||
edges: [
|
||||
{ id: "e1", source: "first", target: "second" },
|
||||
{ id: "e2", source: "second", target: "third" }
|
||||
]
|
||||
});
|
||||
const result = await runFlow(flow, baseOptions((event) => events.push(event)));
|
||||
expect(result.status).toBe("succeeded");
|
||||
|
||||
const finished = events.filter((e) => e.type === "node.finished" && e.status === "succeeded");
|
||||
expect(finished).toHaveLength(3);
|
||||
|
||||
const first = finished.find((e) => e.nodeId === "first")!;
|
||||
expect(first.checkpointId).toBe("first");
|
||||
expect(first.resumable).toBe(true);
|
||||
expect(first.idempotencyKey).toBe("run_cp:first");
|
||||
// 双端透明调度(ADR-R6 补记):本地执行驻地必须随事件上报,供审计区分
|
||||
expect(first.executor).toBe("local");
|
||||
|
||||
const second = finished.find((e) => e.nodeId === "second")!;
|
||||
expect(second.checkpointId).toBe("second");
|
||||
expect(second.resumable).toBe(false);
|
||||
expect(second.idempotencyKey).toBeUndefined();
|
||||
|
||||
const third = finished.find((e) => e.nodeId === "third")!;
|
||||
expect(third.resumable).toBe(true);
|
||||
expect(third.idempotencyKey).toBe("run_cp:third");
|
||||
});
|
||||
|
||||
it("幂等键由 runId+nodeId 派生,跨重入稳定(重入去重的基石)", async () => {
|
||||
const flow = makeFlow({ nodes: [node("only", "Cp_Idempotent")] });
|
||||
const runOnce = async (runId: string): Promise<string | undefined> => {
|
||||
const list: EmittedEvent[] = [];
|
||||
await runFlow(flow, { ...baseOptions((event) => list.push(event)), runId });
|
||||
return list.find((e) => e.type === "node.finished")?.idempotencyKey;
|
||||
};
|
||||
expect(await runOnce("run_a")).toBe("run_a:only");
|
||||
expect(await runOnce("run_a")).toBe("run_a:only");
|
||||
expect(await runOnce("run_b")).toBe("run_b:only");
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AutomationDriver } from "../driver/types";
|
||||
import { createEngine } from "./engine";
|
||||
import { WindowsDriver } from "../driver/windows/windows-driver";
|
||||
|
||||
describe("driver 平台自动选择(迁移指南五件小事 #2)", () => {
|
||||
it("win32 上惰性创建 WindowsDriver 且实例缓存", () => {
|
||||
const engine = createEngine();
|
||||
expect(engine.driver).toBeInstanceOf(WindowsDriver);
|
||||
expect(engine.driver).toBe(engine.driver);
|
||||
});
|
||||
|
||||
it("非 win32 访问 driver 抛 platform_unsupported", () => {
|
||||
const engine = createEngine();
|
||||
const original = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
||||
try {
|
||||
expect(() => engine.driver).toThrowError(/暂不支持桌面自动化驱动/);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: original, configurable: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEngine 注入(迁移阶段1-A1)", () => {
|
||||
const fakeDriver = (): AutomationDriver =>
|
||||
({
|
||||
mouse: {},
|
||||
keyboard: {},
|
||||
window: {},
|
||||
screen: { size: async () => ({ x: 0, y: 0, width: 800, height: 600 }) },
|
||||
element: {}
|
||||
}) as unknown as AutomationDriver;
|
||||
|
||||
it("createEngine 可注入自定义 driver", () => {
|
||||
const driver = fakeDriver();
|
||||
const engine = createEngine({ driver });
|
||||
expect(engine.driver).toBe(driver);
|
||||
});
|
||||
|
||||
it("注入后不触发平台自动选择(darwin 上也可用)", () => {
|
||||
const driver = fakeDriver();
|
||||
const engine = createEngine({ driver });
|
||||
const original = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
||||
try {
|
||||
expect(engine.driver).toBe(driver);
|
||||
expect(engine.driver.screen.size).toBeTruthy();
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: original, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("两个引擎注入不同 driver 互不影响", () => {
|
||||
const driverA = fakeDriver();
|
||||
const driverB = fakeDriver();
|
||||
const engineA = createEngine({ driver: driverA });
|
||||
const engineB = createEngine({ driver: driverB });
|
||||
expect(engineA.driver).toBe(driverA);
|
||||
expect(engineB.driver).toBe(driverB);
|
||||
expect(engineA.driver).not.toBe(engineB.driver);
|
||||
});
|
||||
|
||||
it("setDriver 可后置替换", () => {
|
||||
const engine = createEngine();
|
||||
const driver = fakeDriver();
|
||||
engine.setDriver(driver);
|
||||
expect(engine.driver).toBe(driver);
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowDefinition, FlowNode } from "../types";
|
||||
import { validateFlowGraph, hasBlockingIssues } from "./graph-validator";
|
||||
import { register } from "./registry";
|
||||
import { RuntimeError } from "./types";
|
||||
import { runFlow } from "./runtime";
|
||||
|
||||
type RunFlowOptions = Parameters<typeof runFlow>[1];
|
||||
|
||||
function makeFlow(partial: Partial<FlowDefinition> & { nodes: FlowNode[] }): FlowDefinition {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "flow_test",
|
||||
name: "测试流程",
|
||||
variables: [],
|
||||
nodes: partial.nodes,
|
||||
edges: partial.edges || [],
|
||||
meta: { createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", appVersion: "test" }
|
||||
};
|
||||
}
|
||||
|
||||
function node(id: string, componentId: string, overrides: Partial<FlowNode> = {}): FlowNode {
|
||||
return {
|
||||
id,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const fakeComponent = (id: string): CatalogComponent =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
tags: [],
|
||||
platformTag: null,
|
||||
icon: "",
|
||||
risk: "low",
|
||||
fields: [],
|
||||
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
|
||||
slots: [],
|
||||
functionCalls: []
|
||||
}) as unknown as CatalogComponent;
|
||||
|
||||
const baseOptions = (signal: AbortSignal): RunFlowOptions => ({
|
||||
runId: "run_test",
|
||||
evidenceDir: ".",
|
||||
signal,
|
||||
emit: () => {},
|
||||
log: () => {},
|
||||
componentOf: (n: FlowNode) => fakeComponent(n.componentId),
|
||||
variables: {}
|
||||
});
|
||||
|
||||
const hangUntilAborted = (ctx: { signal: AbortSignal }): Promise<never> =>
|
||||
new Promise((_, reject) => {
|
||||
const onAbort = () => reject(new RuntimeError("cancelled", "运行已取消"));
|
||||
if (ctx.signal.aborted) return onAbort();
|
||||
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
|
||||
register("Test_HangHandler", (ctx) => hangUntilAborted(ctx));
|
||||
|
||||
register("Test_SlowHandler", async (ctx) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new RuntimeError("cancelled", "运行已取消"));
|
||||
};
|
||||
if (ctx.signal.aborted) return onAbort();
|
||||
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
return "done";
|
||||
});
|
||||
|
||||
let nonIdempotentCalls = 0;
|
||||
register("Test_NonIdempotent", async () => {
|
||||
nonIdempotentCalls += 1;
|
||||
throw new Error("boom");
|
||||
}, { idempotent: false });
|
||||
|
||||
let idempotentCalls = 0;
|
||||
register("Test_Idempotent", async () => {
|
||||
idempotentCalls += 1;
|
||||
if (idempotentCalls < 3) throw new Error("transient");
|
||||
return "ok";
|
||||
}, { idempotent: true });
|
||||
|
||||
describe("validateFlowGraph", () => {
|
||||
it("正常图无问题", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual"), node("b", "File_Read")],
|
||||
edges: [{ id: "e1", source: "a", target: "b" }]
|
||||
});
|
||||
expect(hasBlockingIssues(validateFlowGraph(flow))).toBe(false);
|
||||
});
|
||||
|
||||
it("重复节点 ID 记为错误", () => {
|
||||
const flow = makeFlow({ nodes: [node("a", "Trigger.Manual"), node("a", "File_Read")] });
|
||||
const issues = validateFlowGraph(flow);
|
||||
expect(issues.some((issue) => issue.code === "duplicate_node_id" && issue.severity === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("悬空边记为错误", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual")],
|
||||
edges: [{ id: "e1", source: "a", target: "ghost" }]
|
||||
});
|
||||
expect(validateFlowGraph(flow).some((issue) => issue.code === "dangling_edge")).toBe(true);
|
||||
});
|
||||
|
||||
it("自环记为错误", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual")],
|
||||
edges: [{ id: "e1", source: "a", target: "a" }]
|
||||
});
|
||||
expect(validateFlowGraph(flow).some((issue) => issue.code === "self_loop")).toBe(true);
|
||||
});
|
||||
|
||||
it("顶层环记为错误", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual"), node("b", "File_Read"), node("c", "File_Write")],
|
||||
edges: [
|
||||
{ id: "e1", source: "a", target: "b" },
|
||||
{ id: "e2", source: "b", target: "c" },
|
||||
{ id: "e3", source: "c", target: "b" }
|
||||
]
|
||||
});
|
||||
expect(validateFlowGraph(flow).some((issue) => issue.code === "cycle" && issue.severity === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("孤儿子节点与不可达节点", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual"), { ...node("x", "File_Read"), parentId: "ghost" }, node("y", "Http_Get")]
|
||||
});
|
||||
const issues = validateFlowGraph(flow);
|
||||
expect(issues.some((issue) => issue.code === "orphan_child")).toBe(true);
|
||||
expect(issues.some((issue) => issue.code === "unreachable_node")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime 取消/超时/重试门控", () => {
|
||||
it("超时会中止监听尝试信号的 handler", async () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [{ ...node("slow", "Test_SlowHandler"), options: { timeoutMs: 120 } }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error?.code).toBe("timeout");
|
||||
expect(result.error?.reason).toContain("执行超时");
|
||||
});
|
||||
|
||||
it("运行取消时节点状态为 cancelled 且不走 error route", async () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("n1", "Test_HangHandler"), node("err", "File_Write")],
|
||||
edges: [{ id: "e1", source: "n1", target: "err", sourceHandle: "error" }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 50);
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("cancelled");
|
||||
const nodeState = result.nodes.find((item) => item.nodeId === "n1");
|
||||
expect(nodeState?.status).toBe("cancelled");
|
||||
// error 边存在,但取消不应路由到 err 节点
|
||||
expect(result.nodes.some((item) => item.nodeId === "err")).toBe(false);
|
||||
});
|
||||
|
||||
it("非幂等能力忽略 retryCount", async () => {
|
||||
nonIdempotentCalls = 0;
|
||||
const flow = makeFlow({
|
||||
nodes: [{ ...node("r", "Test_NonIdempotent"), options: { retryCount: 3, retryIntervalMs: 1000 } }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(nonIdempotentCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("幂等能力遵循 retryCount 重试后成功", async () => {
|
||||
idempotentCalls = 0;
|
||||
const flow = makeFlow({
|
||||
nodes: [{ ...node("r", "Test_Idempotent"), options: { retryCount: 3, retryIntervalMs: 1000, timeoutMs: 5000 } }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(idempotentCalls).toBe(3);
|
||||
});
|
||||
|
||||
it("流程图校验失败返回 validation_error 且不执行任何节点", async () => {
|
||||
const flow = makeFlow({ nodes: [node("a", "Trigger.Manual"), node("a", "File_Read")] });
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error?.code).toBe("validation_error");
|
||||
expect(result.error?.reason).toContain("流程图校验未通过");
|
||||
expect(result.nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowDefinition } from "../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: []
|
||||
} as unknown as CatalogComponent;
|
||||
components.set(id, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
for (const id of ["Test.Start", "Test.Fail", "Test.Normal", "Test.Recover"]) component(id);
|
||||
register("Test.Start", () => true);
|
||||
register("Test.Fail", () => { throw new Error("expected failure"); });
|
||||
register("Test.Normal", (ctx) => { ctx.vars.normalRan = true; });
|
||||
register("Test.Recover", (ctx) => { ctx.vars.recovered = true; });
|
||||
|
||||
describe("runtime error route", () => {
|
||||
it("uses the error handle after failure and does not execute the normal branch", async () => {
|
||||
const now = new Date().toISOString();
|
||||
const flow: FlowDefinition = {
|
||||
schemaVersion: 1, id: "error-route", name: "error route", variables: [],
|
||||
nodes: [
|
||||
{ id: "start", componentId: "Test.Start", position: { x: 0, y: 0 }, values: {}, options: {} },
|
||||
{ id: "fail", componentId: "Test.Fail", position: { x: 0, y: 100 }, values: {}, options: { errorRouteEnabled: true } },
|
||||
{ id: "normal", componentId: "Test.Normal", position: { x: 0, y: 200 }, values: {}, options: {} },
|
||||
{ id: "recover", componentId: "Test.Recover", position: { x: 200, y: 100 }, values: {}, options: {} }
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "start", target: "fail" },
|
||||
{ id: "e2", source: "fail", target: "normal" },
|
||||
{ id: "e3", source: "fail", sourceHandle: "error", target: "recover" }
|
||||
],
|
||||
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
|
||||
};
|
||||
const result = await runFlow(flow, {
|
||||
runId: "test", evidenceDir: ".", signal: new AbortController().signal,
|
||||
emit: () => {}, log: () => {}, componentOf: (node) => components.get(node.componentId)
|
||||
});
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables.recovered).toBe(true);
|
||||
expect(result.variables.normalRan).toBeUndefined();
|
||||
expect(result.variables.error).toMatchObject({ reason: "expected failure" });
|
||||
});
|
||||
|
||||
it("uses the normal handle after success and leaves error and disconnected nodes untouched", async () => {
|
||||
const now = new Date().toISOString();
|
||||
const flow: FlowDefinition = {
|
||||
schemaVersion: 1, id: "normal-route", name: "normal route", variables: [],
|
||||
nodes: [
|
||||
{ id: "start", componentId: "Test.Start", position: { x: 0, y: 0 }, values: {}, options: {} },
|
||||
{ id: "normal", componentId: "Test.Normal", position: { x: 0, y: 100 }, values: {}, options: {} },
|
||||
{ id: "recover", componentId: "Test.Recover", position: { x: 200, y: 0 }, values: {}, options: {} },
|
||||
{ id: "orphan", componentId: "Test.Recover", position: { x: 400, y: 400 }, values: {}, options: {} }
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", source: "start", target: "normal" },
|
||||
{ id: "e2", source: "start", sourceHandle: "error", target: "recover" }
|
||||
],
|
||||
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
|
||||
};
|
||||
const result = await runFlow(flow, {
|
||||
runId: "test", evidenceDir: ".", signal: new AbortController().signal,
|
||||
emit: () => {}, log: () => {}, componentOf: (node) => components.get(node.componentId)
|
||||
});
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables.normalRan).toBe(true);
|
||||
expect(result.variables.recovered).toBeUndefined();
|
||||
expect(result.nodes.map((state) => state.nodeId)).toEqual(["start", "normal"]);
|
||||
});
|
||||
});
|
||||
@@ -1,313 +0,0 @@
|
||||
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 "../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: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-runtime-integration-"));
|
||||
tempDirs.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function execute(
|
||||
definition: FlowDefinition,
|
||||
variables: Record<string, unknown> = {},
|
||||
extraComponents: CatalogComponent[] = []
|
||||
) {
|
||||
const directory = await tempDir();
|
||||
const events: Array<Omit<RunEvent, "runId">> = [];
|
||||
const logs: Array<{ message: string; level?: "info" | "warn" | "error"; nodeId?: string }> = [];
|
||||
const componentMap = new Map(components);
|
||||
for (const component of extraComponents) componentMap.set(component.id, component);
|
||||
|
||||
const result = await runFlow(definition, {
|
||||
runId: `run-${definition.id}`,
|
||||
evidenceDir: directory,
|
||||
signal: new AbortController().signal,
|
||||
variables,
|
||||
emit: (event) => events.push(event),
|
||||
log: (message, level, nodeId) => logs.push({ message, level, nodeId }),
|
||||
componentOf: (current) => componentMap.get(current.componentId)
|
||||
});
|
||||
|
||||
return { result, events, logs, directory };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("runFlow integration", () => {
|
||||
it("follows edges in a straight line and passes variables between real capabilities", async () => {
|
||||
const definition = flow(
|
||||
"straight-variable-flow",
|
||||
[
|
||||
node("debug", "Basic_DebugOutput", { msg: "${upper}" }, {}, { position: { x: 0, y: 10 } }),
|
||||
node("upper", "StrUtil_UpperCase", { string: "${source}" }, { outputVar: "upper" }),
|
||||
node("trigger", "Trigger.Manual", {}, {}, { position: { x: 0, y: 300 } }),
|
||||
node("set", "Basic_SetVariable", { value: "hello rpa" }, { outputVar: "source" })
|
||||
],
|
||||
[edge("trigger", "set"), edge("set", "upper"), edge("upper", "debug")]
|
||||
);
|
||||
|
||||
const { result, events, logs } = await execute(definition);
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables).toMatchObject({ source: "hello rpa", upper: "HELLO RPA" });
|
||||
expect(result.nodes.map((state) => state.nodeId)).toEqual(["trigger", "set", "upper", "debug"]);
|
||||
expect(events.filter((event) => event.type === "node.started").map((event) => event.nodeId)).toEqual([
|
||||
"trigger",
|
||||
"set",
|
||||
"upper",
|
||||
"debug"
|
||||
]);
|
||||
expect(logs.some((entry) => entry.nodeId === "debug" && entry.message === "HELLO RPA")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ approved: true, expected: "true", executed: "true-node", absent: "false-node" },
|
||||
{ approved: false, expected: "false", executed: "false-node", absent: "true-node" }
|
||||
])("executes only the IF $expected branch", async ({ approved, expected, executed, absent }) => {
|
||||
const branch = node("branch", "logic_control_if_else", { condition: "${approved}" });
|
||||
const definition = flow("if-branch-flow", [
|
||||
branch,
|
||||
node("true-node", "Basic_SetVariable", { value: "true" }, { outputVar: "branchResult" }, {
|
||||
parentId: branch.id,
|
||||
slot: "statement_if"
|
||||
}),
|
||||
node("false-node", "Basic_SetVariable", { value: "false" }, { outputVar: "branchResult" }, {
|
||||
parentId: branch.id,
|
||||
slot: "statement_else"
|
||||
})
|
||||
]);
|
||||
|
||||
const { result } = await execute(definition, { approved });
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables.branchResult).toBe(expected);
|
||||
expect(result.nodes.find((state) => state.nodeId === executed)?.status).toBe("succeeded");
|
||||
expect(result.nodes.some((state) => state.nodeId === absent)).toBe(false);
|
||||
expect(result.nodes.find((state) => state.nodeId === "branch")?.output).toBe(approved);
|
||||
});
|
||||
|
||||
it("runs a count loop inclusively and exposes the loop index to child nodes", async () => {
|
||||
const loop = node("loop", "logic_control_count_for", {
|
||||
count_index: "iteration",
|
||||
count_start_value: "1",
|
||||
count_end_value: "3",
|
||||
count_step: "1"
|
||||
});
|
||||
const definition = flow("count-loop-flow", [
|
||||
loop,
|
||||
node("accumulate", "Basic_SetVariable", { value: "${total + iteration}" }, { outputVar: "total" }, {
|
||||
parentId: loop.id,
|
||||
slot: "count_for_fn"
|
||||
})
|
||||
]);
|
||||
|
||||
const { result, events } = await execute(definition, { total: 0 });
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables).toMatchObject({ iteration: 3, total: 6 });
|
||||
expect(result.nodes.find((state) => state.nodeId === "loop")?.output).toBe(3);
|
||||
expect(events.filter((event) => event.type === "node.started" && event.nodeId === "accumulate")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("runs an array loop through the last item when end_index is -1", async () => {
|
||||
const loop = node("loop", "logic_control_array_for_v3", {
|
||||
array_name: "items",
|
||||
reverse: false,
|
||||
start_index: 0,
|
||||
end_index: -1
|
||||
});
|
||||
const definition = flow("array-loop-flow", [
|
||||
loop,
|
||||
node("collect", "Basic_DebugOutput", { msg: "${item}" }, {}, {
|
||||
parentId: loop.id,
|
||||
slot: "array_for_fn"
|
||||
})
|
||||
]);
|
||||
|
||||
const { result, events } = await execute(definition, { items: ["glm", "mini", "other", "kimi"] });
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.nodes.find((state) => state.nodeId === "loop")?.output).toBe(4);
|
||||
expect(events.filter((event) => event.type === "node.started" && event.nodeId === "collect").map((event) => event.nodeId)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("retries a transient failure and records the successful attempt", async () => {
|
||||
const component = testComponent("Test.TransientFailure");
|
||||
let calls = 0;
|
||||
register(component.id, () => {
|
||||
calls += 1;
|
||||
if (calls < 3) throw new Error(`temporary failure ${calls}`);
|
||||
return "recovered";
|
||||
});
|
||||
const definition = flow("retry-flow", [
|
||||
node("retry", component.id, {}, { outputVar: "retryResult", retryCount: 2, retryIntervalMs: 1_000 })
|
||||
]);
|
||||
|
||||
const { result, logs } = await execute(definition, {}, [component]);
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(calls).toBe(3);
|
||||
expect(result.variables.retryResult).toBe("recovered");
|
||||
expect(result.nodes[0]).toMatchObject({ status: "succeeded", attempt: 3, output: "recovered" });
|
||||
expect(logs.filter((entry) => entry.level === "warn" && entry.nodeId === "retry")).toHaveLength(2);
|
||||
}, 5_000);
|
||||
|
||||
it("continues after a skipped failure and executes the following node", async () => {
|
||||
const component = testComponent("Test.AlwaysFails");
|
||||
register(component.id, () => {
|
||||
throw new Error("expected failure");
|
||||
});
|
||||
const definition = flow(
|
||||
"skip-on-error-flow",
|
||||
[
|
||||
node("failure", component.id, {}, { skipOnError: true }),
|
||||
node("after", "Basic_SetVariable", { value: "continued" }, { outputVar: "afterFailure" })
|
||||
],
|
||||
[edge("failure", "after")]
|
||||
);
|
||||
|
||||
const { result } = await execute(definition, {}, [component]);
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.nodes.find((state) => state.nodeId === "failure")).toMatchObject({
|
||||
status: "skipped",
|
||||
error: { code: "unknown", reason: "expected failure" }
|
||||
});
|
||||
expect(result.nodes.find((state) => state.nodeId === "after")?.status).toBe("succeeded");
|
||||
expect(result.variables.afterFailure).toBe("continued");
|
||||
});
|
||||
|
||||
it("runs an offline file pipeline with interpolation and persisted output", async () => {
|
||||
const directory = await tempDir();
|
||||
const sourcePath = path.join(directory, "source.txt");
|
||||
const targetPath = path.join(directory, "result.txt");
|
||||
const definition = flow(
|
||||
"file-pipeline-flow",
|
||||
[
|
||||
node("write-source", "File_Write", {
|
||||
path: "${sourcePath}",
|
||||
content: "${inputText}",
|
||||
encode: "utf-8",
|
||||
append: "False"
|
||||
}),
|
||||
node("read-source", "File_Read", { path: "${sourcePath}", encode: "utf-8" }, { outputVar: "fileText" }),
|
||||
node("transform", "StrUtil_UpperCase", { string: "${fileText}" }, { outputVar: "transformed" }),
|
||||
node("write-target", "File_Write", {
|
||||
path: "${targetPath}",
|
||||
content: "${transformed}",
|
||||
encode: "utf-8",
|
||||
append: "False"
|
||||
}),
|
||||
node("read-target", "File_Read", { path: "${targetPath}", encode: "utf-8" }, { outputVar: "finalText" })
|
||||
],
|
||||
[
|
||||
edge("write-source", "read-source"),
|
||||
edge("read-source", "transform"),
|
||||
edge("transform", "write-target"),
|
||||
edge("write-target", "read-target")
|
||||
]
|
||||
);
|
||||
|
||||
const { result } = await execute(definition, {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
inputText: "offline rpa pipeline"
|
||||
});
|
||||
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(result.variables).toMatchObject({
|
||||
fileText: "offline rpa pipeline",
|
||||
transformed: "OFFLINE RPA PIPELINE",
|
||||
finalText: "OFFLINE RPA PIPELINE"
|
||||
});
|
||||
await expect(readFile(targetPath, "utf8")).resolves.toBe("OFFLINE RPA PIPELINE");
|
||||
expect(result.nodes.every((state) => state.status === "succeeded")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user