feat: 扩展网页Office能力并增加运行前检查
This commit is contained in:
1 parent
f9688251ea
commit
21b5186442
12 files changed
+1045
-4
No files matched your search
@@ -0,0 +1,185 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import { RuntimeError, type BrowserLike, type RunContext } from "../engine/types";
|
||||
import "./browser-extended";
|
||||
|
||||
const EXTENDED_IDS = [
|
||||
"Element_Click_V1", "Element_Click_V2", "Element_Click_V4", "Element_Click_V5",
|
||||
"Element_SetValue_V1", "Element_SetValue_V2", "Element_SetValue_V3", "Element_SetValue_V5",
|
||||
"Element_SetValuePassword", "Element_SetValuePassword_V1", "Element_SetValuePassword_V5",
|
||||
"Element_SetValuePassword_V6", "Element_GetText_V1", "Element_GetText_V2", "Element_GetText_V3",
|
||||
"Element_GetText_V4", "Element_GetValue", "Element_WaitElement", "Element_WaitElement_v1",
|
||||
"Element_WaitElement_v2", "Element_WaitElement_v3", "Element_WaitElement_v4",
|
||||
"Element_SelectOperation_v2", "Element_SelectOperation_v3"
|
||||
] as const;
|
||||
|
||||
const component = {} as CatalogComponent;
|
||||
|
||||
function node(componentId: string): FlowNode {
|
||||
return { id: `extended_${componentId}`, componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
|
||||
}
|
||||
|
||||
function fakeBrowser(): BrowserLike {
|
||||
return {
|
||||
navigate: vi.fn(async (url: string) => ({ url, title: "Fake page" })),
|
||||
click: vi.fn(async (selector: unknown) => ({ selector })),
|
||||
type: vi.fn(async (selector: unknown, text: string, clear?: boolean) => ({ selector, text, clear })),
|
||||
getText: vi.fn(async (_selector: unknown, attribute?: string) => attribute ? `attr:${attribute}` : "fake text"),
|
||||
exists: vi.fn(async () => true),
|
||||
select: vi.fn(async (selector: unknown, value: string) => ({ selector, value })),
|
||||
runJs: vi.fn(async () => true),
|
||||
download: vi.fn(async () => "download.bin"),
|
||||
screenshot: vi.fn(async (target: string) => target),
|
||||
currentUrl: vi.fn(async () => "about:blank"),
|
||||
close: vi.fn(async () => undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function context(browser: BrowserLike): RunContext {
|
||||
return {
|
||||
runId: "browser-extended-run",
|
||||
flowId: "browser-extended-flow",
|
||||
flow: {} as RunContext["flow"],
|
||||
vars: {},
|
||||
secrets: {},
|
||||
signal: new AbortController().signal,
|
||||
evidenceDir: "C:/temp/evidence",
|
||||
browser,
|
||||
emit: vi.fn(),
|
||||
log: vi.fn(),
|
||||
componentOf: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(ctx: RunContext, componentId: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const handler = getHandler(componentId);
|
||||
expect(handler, `${componentId} should be registered`).toBeTypeOf("function");
|
||||
return handler!(ctx, node(componentId), args, component);
|
||||
}
|
||||
|
||||
async function expectValidationError(promise: Promise<unknown>): Promise<void> {
|
||||
await expect(promise).rejects.toSatisfy((error: unknown) =>
|
||||
error instanceof RuntimeError && error.code === "validation_error"
|
||||
);
|
||||
}
|
||||
|
||||
describe("extended browser component registration", () => {
|
||||
it("registers only component IDs that exist in the catalog", () => {
|
||||
const catalog = JSON.parse(readFileSync(resolve("resources/catalog.json"), "utf8")) as {
|
||||
components: Array<{ id: string }>;
|
||||
};
|
||||
const catalogIds = new Set(catalog.components.map((item) => item.id));
|
||||
expect(EXTENDED_IDS).toHaveLength(24);
|
||||
expect(EXTENDED_IDS.filter((id) => !catalogIds.has(id))).toEqual([]);
|
||||
expect(EXTENDED_IDS.every((id) => typeof getHandler(id) === "function")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extended browser component routing", () => {
|
||||
let browser: BrowserLike;
|
||||
let ctx: RunContext;
|
||||
const selector = { kind: "css", value: "#target" };
|
||||
|
||||
beforeEach(() => {
|
||||
browser = fakeBrowser();
|
||||
ctx = context(browser);
|
||||
});
|
||||
|
||||
it.each(["Element_Click_V1", "Element_Click_V2", "Element_Click_V4", "Element_Click_V5"])(
|
||||
"%s routes a default left click",
|
||||
async (componentId) => {
|
||||
await invoke(ctx, componentId, { uiSelector: selector });
|
||||
expect(browser.click).toHaveBeenCalledWith(selector);
|
||||
}
|
||||
);
|
||||
|
||||
it("rejects unsupported click modes instead of silently changing their meaning", async () => {
|
||||
await expectValidationError(invoke(ctx, "Element_Click_V2", { uiSelector: selector, clickfunc: "1" }));
|
||||
expect(browser.click).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Element_SetValue_V1", { value: "append", input: "1" }, "append", false],
|
||||
["Element_SetValue_V2", { text: "replace", input_type: "0" }, "replace", true],
|
||||
["Element_SetValue_V3", { text: "append", input_type: "1" }, "append", false],
|
||||
["Element_SetValue_V5", { text: "replace", input_type: "0" }, "replace", true]
|
||||
])("%s routes text and replacement mode", async (componentId, args, expectedText, expectedClear) => {
|
||||
await invoke(ctx, componentId, { uiSelector: selector, ...args });
|
||||
expect(browser.type).toHaveBeenCalledWith(selector, expectedText, expectedClear);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Element_SetValuePassword", "Element_SetValuePassword_V1",
|
||||
"Element_SetValuePassword_V5", "Element_SetValuePassword_V6"
|
||||
])("%s types a password without logging its value", async (componentId) => {
|
||||
await invoke(ctx, componentId, { uiSelector: selector, text: "secret-value" });
|
||||
expect(browser.type).toHaveBeenCalledWith(selector, "secret-value", true);
|
||||
expect(ctx.log).toHaveBeenCalledWith("已输入密码", "info", `extended_${componentId}`);
|
||||
expect(JSON.stringify(vi.mocked(ctx.log).mock.calls)).not.toContain("secret-value");
|
||||
});
|
||||
|
||||
it("submits the active input when Enter is requested", async () => {
|
||||
await invoke(ctx, "Element_SetValue_V3", { uiSelector: selector, text: "query", isEnter: "1" });
|
||||
expect(browser.runJs).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(browser.runJs).mock.calls[0][0]).toContain("requestSubmit");
|
||||
});
|
||||
|
||||
it.each(["Element_GetText_V1", "Element_GetText_V2", "Element_GetText_V3", "Element_GetText_V4"])(
|
||||
"%s reads element text",
|
||||
async (componentId) => {
|
||||
await expect(invoke(ctx, componentId, { uiSelector: selector })).resolves.toBe("fake text");
|
||||
expect(browser.getText).toHaveBeenCalledWith(selector, undefined);
|
||||
}
|
||||
);
|
||||
|
||||
it("routes requested attributes and the GetValue variant", async () => {
|
||||
await expect(invoke(ctx, "Element_GetText_V3", {
|
||||
uiSelector: selector, attr_type: "attribute", attr_name: "data-state"
|
||||
})).resolves.toBe("attr:data-state");
|
||||
await expect(invoke(ctx, "Element_GetValue", { uiSelector: selector })).resolves.toBe("fake text");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Element_WaitElement", "Element_WaitElement_v1", "Element_WaitElement_v2",
|
||||
"Element_WaitElement_v3", "Element_WaitElement_v4"
|
||||
])("%s resolves when the requested presence condition is met", async (componentId) => {
|
||||
await expect(invoke(ctx, componentId, { uiSelector: selector, timeout: 0, time_out: 0 })).resolves.toBe(true);
|
||||
expect(browser.exists).toHaveBeenCalledWith(selector);
|
||||
});
|
||||
|
||||
it("supports waiting for disappearance and reports timeout as false", async () => {
|
||||
vi.mocked(browser.exists).mockResolvedValueOnce(false);
|
||||
await expect(invoke(ctx, "Element_WaitElement_v4", {
|
||||
uiSelector: selector, choose: "3", time_out: 0
|
||||
})).resolves.toBe(true);
|
||||
|
||||
vi.mocked(browser.exists).mockResolvedValue(true);
|
||||
await expect(invoke(ctx, "Element_WaitElement", {
|
||||
uiSelector: selector, choose: "3", timeout: 0
|
||||
})).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("routes v2 and v3 single-option selections", async () => {
|
||||
await invoke(ctx, "Element_SelectOperation_v2", {
|
||||
uiSelector: selector, itemsToSelect: '["approved"]'
|
||||
});
|
||||
expect(browser.select).toHaveBeenCalledWith(selector, "approved");
|
||||
|
||||
await invoke(ctx, "Element_SelectOperation_v3", {
|
||||
uiSelector: selector, select_value_str: "pending"
|
||||
});
|
||||
expect(browser.select).toHaveBeenCalledWith(selector, "pending");
|
||||
});
|
||||
|
||||
it("rejects missing selectors, image text mode and unsupported select queries", async () => {
|
||||
await expectValidationError(invoke(ctx, "Element_Click_V1", {}));
|
||||
await expectValidationError(invoke(ctx, "Element_GetText_V2", { uiSelector: selector, choose: "1" }));
|
||||
await expectValidationError(invoke(ctx, "Element_SelectOperation_v2", {
|
||||
uiSelector: selector, operation_type: "2", itemsToSelect: '["approved"]'
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { browserSession } from "../browser/session";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type BrowserLike, type RunContext } from "../engine/types";
|
||||
|
||||
const CLICK_IDS = ["Element_Click_V1", "Element_Click_V2", "Element_Click_V4", "Element_Click_V5"];
|
||||
const TEXT_INPUT_IDS = ["Element_SetValue_V1", "Element_SetValue_V2", "Element_SetValue_V3", "Element_SetValue_V5"];
|
||||
const PASSWORD_INPUT_IDS = [
|
||||
"Element_SetValuePassword",
|
||||
"Element_SetValuePassword_V1",
|
||||
"Element_SetValuePassword_V5",
|
||||
"Element_SetValuePassword_V6"
|
||||
];
|
||||
const GET_TEXT_IDS = ["Element_GetText_V1", "Element_GetText_V2", "Element_GetText_V3", "Element_GetText_V4"];
|
||||
const WAIT_IDS = [
|
||||
"Element_WaitElement",
|
||||
"Element_WaitElement_v1",
|
||||
"Element_WaitElement_v2",
|
||||
"Element_WaitElement_v3",
|
||||
"Element_WaitElement_v4"
|
||||
];
|
||||
|
||||
async function session(ctx: RunContext): Promise<BrowserLike> {
|
||||
if (!ctx.browser) ctx.browser = await browserSession();
|
||||
return ctx.browser;
|
||||
}
|
||||
|
||||
function selectorOf(args: Record<string, unknown>): unknown {
|
||||
const selector = args.uiSelector ?? args.selector ?? args.target;
|
||||
if (selector === undefined || selector === null || selector === "") {
|
||||
throw new RuntimeError("validation_error", "元素选择器为空", {
|
||||
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
|
||||
});
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
|
||||
function enabled(value: unknown): boolean {
|
||||
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
|
||||
}
|
||||
|
||||
function numberArg(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function assertUnifiedBrowser(args: Record<string, unknown>): void {
|
||||
if (enabled(args.designated_info)) {
|
||||
throw new RuntimeError("validation_error", "当前网页能力不支持指定其他网页或窗口对象", {
|
||||
recommendation: "关闭“指定网页/窗口对象”,使用当前统一运行浏览器"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertSimpleClick(args: Record<string, unknown>): void {
|
||||
const mouseButton = String(args.clickway ?? "0");
|
||||
const clickType = String(args.clickfunc ?? "0");
|
||||
if (mouseButton !== "0" || clickType !== "0") {
|
||||
throw new RuntimeError("validation_error", "当前网页点击变体仅支持默认的鼠标左键单击", {
|
||||
recommendation: "将鼠标按键和点击类型恢复为默认值"
|
||||
});
|
||||
}
|
||||
if (args.click_data !== undefined && args.click_data !== null && args.click_data !== "") {
|
||||
throw new RuntimeError("validation_error", "当前网页点击变体不支持自定义点击偏移", {
|
||||
recommendation: "清空点击位置配置后重试"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearFirst(args: Record<string, unknown>): boolean {
|
||||
const append = args.input_type ?? args.input ?? "0";
|
||||
return !enabled(append);
|
||||
}
|
||||
|
||||
function textOf(args: Record<string, unknown>): string {
|
||||
return String(args.text ?? args.value ?? "");
|
||||
}
|
||||
|
||||
async function submitActiveElement(browser: BrowserLike, delayMs: number): Promise<void> {
|
||||
if (delayMs > 0) await wait(delayMs);
|
||||
await browser.runJs(`(() => {
|
||||
const element = document.activeElement;
|
||||
if (!element) return false;
|
||||
const options = { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true };
|
||||
const accepted = element.dispatchEvent(new KeyboardEvent("keydown", options));
|
||||
element.dispatchEvent(new KeyboardEvent("keyup", options));
|
||||
if (accepted && element.form && typeof element.form.requestSubmit === "function") element.form.requestSubmit();
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
|
||||
function attributeOf(args: Record<string, unknown>): string | undefined {
|
||||
const type = String(args.attr_type ?? "default");
|
||||
if (!type || type === "default") return undefined;
|
||||
return String(args.attr_name ?? type);
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function waitSettings(componentId: string, args: Record<string, unknown>): {
|
||||
expected: boolean;
|
||||
timeoutMs: number;
|
||||
intervalMs: number;
|
||||
} {
|
||||
const choose = String(args.choose ?? "1");
|
||||
const expected = choose !== "3";
|
||||
const secondsBased = /_v[234]$/.test(componentId);
|
||||
const timeout = numberArg(args.time_out ?? args.timeout, secondsBased ? 10 : 10000);
|
||||
const interval = numberArg(args.time_gap, secondsBased && /_v[34]$/.test(componentId) ? 1 : 200);
|
||||
return {
|
||||
expected,
|
||||
timeoutMs: secondsBased ? timeout * 1000 : timeout,
|
||||
intervalMs: secondsBased && /_v[34]$/.test(componentId) ? interval * 1000 : interval
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForElement(
|
||||
browser: BrowserLike,
|
||||
selector: unknown,
|
||||
expected: boolean,
|
||||
timeoutMs: number,
|
||||
intervalMs: number,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
do {
|
||||
if (signal.aborted) throw new DOMException("运行已取消", "AbortError");
|
||||
if ((await browser.exists(selector)) === expected) return true;
|
||||
if (Date.now() >= deadline) return false;
|
||||
await wait(Math.max(1, Math.min(intervalMs, deadline - Date.now())));
|
||||
} while (Date.now() <= deadline);
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectionValue(args: Record<string, unknown>): string {
|
||||
const direct = args.select_value_str ?? args.itemsToSelect ?? args.value;
|
||||
if (Array.isArray(direct)) return direct.length ? String(direct[0]) : "";
|
||||
const text = String(direct ?? "").trim();
|
||||
if (!text) return "";
|
||||
if (text.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (Array.isArray(parsed)) return parsed.length ? String(parsed[0]) : "";
|
||||
} catch {
|
||||
// Treat a malformed JSON-looking value as a literal option value.
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
register(CLICK_IDS, async (ctx, node, args) => {
|
||||
assertSimpleClick(args);
|
||||
const browser = await session(ctx);
|
||||
const result = await browser.click(selectorOf(args));
|
||||
ctx.log("已点击网页元素", "info", node.id);
|
||||
return result;
|
||||
});
|
||||
|
||||
register(TEXT_INPUT_IDS, async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const text = textOf(args);
|
||||
const result = await browser.type(selectorOf(args), text, clearFirst(args));
|
||||
if (enabled(args.isEnter)) await submitActiveElement(browser, numberArg(args.delaybeforEnter, 0));
|
||||
ctx.log(`已输入 ${text.length > 40 ? `${text.slice(0, 40)}...` : text}`, "info", node.id);
|
||||
return result;
|
||||
});
|
||||
|
||||
register(PASSWORD_INPUT_IDS, async (ctx, node, args) => {
|
||||
assertUnifiedBrowser(args);
|
||||
const browser = await session(ctx);
|
||||
const result = await browser.type(selectorOf(args), textOf(args), clearFirst(args));
|
||||
if (enabled(args.isEnter)) await submitActiveElement(browser, numberArg(args.delaybeforEnter, 0));
|
||||
ctx.log("已输入密码", "info", node.id);
|
||||
return result;
|
||||
});
|
||||
|
||||
register(GET_TEXT_IDS, async (ctx, node, args) => {
|
||||
assertUnifiedBrowser(args);
|
||||
if (String(args.choose ?? "0") !== "0") {
|
||||
throw new RuntimeError("validation_error", "当前取文本变体仅支持网页元素选择器", {
|
||||
recommendation: "将获取类型设为网页元素,而不是图像区域"
|
||||
});
|
||||
}
|
||||
const browser = await session(ctx);
|
||||
const text = await browser.getText(selectorOf(args), attributeOf(args));
|
||||
ctx.log(`读取到 ${text.length > 60 ? `${text.slice(0, 60)}...` : text}`, "info", node.id);
|
||||
return text;
|
||||
});
|
||||
|
||||
register("Element_GetValue", async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const value = await browser.getText(selectorOf(args));
|
||||
ctx.log("已获取网页元素值", "info", node.id);
|
||||
return value;
|
||||
});
|
||||
|
||||
register(WAIT_IDS, async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const settings = waitSettings(node.componentId, args);
|
||||
const matched = await waitForElement(
|
||||
browser,
|
||||
selectorOf(args),
|
||||
settings.expected,
|
||||
settings.timeoutMs,
|
||||
settings.intervalMs,
|
||||
ctx.signal
|
||||
);
|
||||
ctx.log(matched ? "元素等待条件已满足" : "元素等待超时", matched ? "info" : "warn", node.id);
|
||||
return matched;
|
||||
});
|
||||
|
||||
register(["Element_SelectOperation_v2", "Element_SelectOperation_v3"], async (ctx, node, args) => {
|
||||
assertUnifiedBrowser(args);
|
||||
if (String(args.operation_type ?? "1") !== "1") {
|
||||
throw new RuntimeError("validation_error", "当前下拉框变体仅支持选择选项", {
|
||||
recommendation: "将操作类型设为选择;获取全部选项暂未接入"
|
||||
});
|
||||
}
|
||||
const value = selectionValue(args);
|
||||
if (!value) {
|
||||
throw new RuntimeError("validation_error", "下拉框选项值为空", { recommendation: "填写一个要选择的选项值" });
|
||||
}
|
||||
const browser = await session(ctx);
|
||||
const result = await browser.select(selectorOf(args), value);
|
||||
ctx.log(`已选择 ${value}`, "info", node.id);
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import "./files";
|
||||
import "./http";
|
||||
import "./data";
|
||||
import "./browser";
|
||||
import "./browser-extended";
|
||||
import "./office";
|
||||
import "./desktop";
|
||||
import "./pdf";
|
||||
@@ -11,5 +12,6 @@ import "./message";
|
||||
import "./vision";
|
||||
import "./extended";
|
||||
import "./system-extended";
|
||||
import "./office-file-extended";
|
||||
|
||||
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
|
||||
@@ -0,0 +1,126 @@
|
||||
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 "../../shared/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"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
|
||||
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
|
||||
|
||||
function value(args: Record<string, unknown>, keys: string[], fallback: unknown = ""): unknown {
|
||||
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return args[key];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||||
return String(value(args, keys, fallback));
|
||||
}
|
||||
|
||||
function truthy(candidate: unknown): boolean {
|
||||
return [true, 1, "1", "true", "True", "yes", "on"].includes(candidate as never);
|
||||
}
|
||||
|
||||
function requiredPath(candidate: unknown, label: string, nodeId: string, componentId: string): string {
|
||||
const raw = typeof candidate === "object" && candidate !== null ? (candidate as { path?: unknown }).path : candidate;
|
||||
const filePath = String(raw ?? "").trim();
|
||||
if (!filePath) throw new RuntimeError("validation_error", `${label} is empty`, { nodeId, componentId, recommendation: `Configure ${label}` });
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
|
||||
async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> {
|
||||
const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", nodeId, componentId);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(filePath);
|
||||
return { workbook, filePath };
|
||||
}
|
||||
|
||||
function worksheetOf(workbook: ExcelJS.Workbook, args: Record<string, unknown>): ExcelJS.Worksheet {
|
||||
const name = text(args, ["sheet", "sheetName"]);
|
||||
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
|
||||
if (!sheet) throw new Error(`Sheet not found: ${name || "first sheet"}`);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
async function saveWhenRequested(source: WorkbookSource, args: Record<string, unknown>): Promise<void> {
|
||||
if (truthy(value(args, ["save", "isSave"]))) await source.workbook.xlsx.writeFile(source.filePath);
|
||||
}
|
||||
|
||||
function columnNumber(candidate: unknown): number {
|
||||
const raw = String(candidate ?? "A").trim().toUpperCase();
|
||||
if (/^\d+$/.test(raw)) return Math.max(1, Number(raw));
|
||||
if (!/^[A-Z]+$/.test(raw)) throw new Error(`Invalid column: ${raw}`);
|
||||
let result = 0;
|
||||
for (const character of raw) result = result * 26 + character.charCodeAt(0) - 64;
|
||||
return result;
|
||||
}
|
||||
|
||||
function columnLetter(index: number): string {
|
||||
if (!Number.isInteger(index) || index < 1) throw new Error(`Invalid column number: ${index}`);
|
||||
let current = index;
|
||||
let result = "";
|
||||
while (current > 0) {
|
||||
current -= 1;
|
||||
result = String.fromCharCode(65 + current % 26) + result;
|
||||
current = Math.floor(current / 26);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function cellValue(candidate: ExcelJS.CellValue): unknown {
|
||||
if (candidate && typeof candidate === "object" && "result" in candidate) return candidate.result ?? "";
|
||||
if (candidate && typeof candidate === "object" && "text" in candidate) return candidate.text;
|
||||
return candidate ?? "";
|
||||
}
|
||||
|
||||
function parseList(candidate: unknown, converter: (item: string) => number): number[] {
|
||||
if (Array.isArray(candidate)) return candidate.flatMap((item) => parseList(item, converter));
|
||||
const raw = String(candidate ?? "").trim();
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed.flatMap((item) => parseList(item, converter));
|
||||
} catch { /* parse as a delimited value */ }
|
||||
return raw.split(/[,;\s]+/).filter(Boolean).flatMap((part) => {
|
||||
const match = part.match(/^([A-Za-z]+|\d+)[-:]([A-Za-z]+|\d+)$/);
|
||||
if (!match) return [converter(part)];
|
||||
const start = converter(match[1]);
|
||||
const end = converter(match[2]);
|
||||
return Array.from({ length: Math.abs(end - start) + 1 }, (_, index) => Math.min(start, end) + index);
|
||||
});
|
||||
}
|
||||
|
||||
const readRowCount: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const byColumn = text(args, ["range"]).includes("列");
|
||||
const column = columnNumber(value(args, ["column"], "A"));
|
||||
let count = 0;
|
||||
sheet.eachRow({ includeEmpty: false }, (row) => {
|
||||
if (truthy(args.ignoreHidden) && row.hidden) return;
|
||||
if (!byColumn || cellValue(row.getCell(column).value) !== "") count += 1;
|
||||
});
|
||||
return count;
|
||||
};
|
||||
register("Excel_Cs_ReadRowCount", readRowCount);
|
||||
|
||||
register("Excel_Cs_ReadCellFormula", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const address = `${columnLetter(columnNumber(value(args, ["column"], "A")))}${Math.max(1, Number(value(args, ["row"], 1)))}`;
|
||||
const candidate = sheet.getCell(address).value;
|
||||
return candidate && typeof candidate === "object" && "formula" in candidate ? candidate.formula : "";
|
||||
});
|
||||
|
||||
const getExcelPath: CapabilityHandler = (_ctx, node, args) =>
|
||||
requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", node.id, node.componentId);
|
||||
register(["Excel_Cs_GetPath", "Excel_GetPath"], getExcelPath);
|
||||
|
||||
register("Excel_Cs_ConvertColumn", (_ctx, _node, args) => {
|
||||
const input = value(args, ["convertInput", "column"], 1);
|
||||
return text(args, ["convertType"], "0") === "0" ? columnLetter(Number(input)) : columnNumber(input);
|
||||
});
|
||||
|
||||
register("Excel_Cs_GetEmptyCell", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const column = columnNumber(value(args, ["column"], "A"));
|
||||
let row = Math.max(1, Number(value(args, ["row"], 1)));
|
||||
while (cellValue(sheet.getCell(row, column).value) !== "") row += 1;
|
||||
return `${columnLetter(column)}${row}`;
|
||||
});
|
||||
|
||||
const readColumn: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const column = columnNumber(value(args, ["columnName", "column", "cellPos"], "A"));
|
||||
const startRow = Math.max(1, Number(value(args, ["row", "startRow"], 1)));
|
||||
return Array.from({ length: Math.max(0, sheet.rowCount - startRow + 1) }, (_, index) => cellValue(sheet.getCell(startRow + index, column).value));
|
||||
};
|
||||
register(["Excel_Cs_ReadColumn", "Excel_GetColDataNew"], readColumn);
|
||||
|
||||
register("Excel_Cs_DeleteRow", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const mode = text(args, ["mode"]);
|
||||
const input = mode.includes("多") ? args.rows : mode.includes("范围") ? `${value(args, ["startRow"], 1)}-${value(args, ["lastRow"], 1)}` : value(args, ["row"], 1);
|
||||
const rows = parseList(input, Number);
|
||||
for (const row of [...new Set(rows)].sort((a, b) => b - a)) sheet.spliceRows(row, 1);
|
||||
await saveWhenRequested(source, args);
|
||||
return rows.length;
|
||||
});
|
||||
|
||||
register("Excel_Cs_DeleteColumn", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const mode = text(args, ["mode"]);
|
||||
const input = mode.includes("多") ? args.columns : mode.includes("范围") ? `${value(args, ["startColumn"], "A")}-${value(args, ["lastColumn"], "A")}` : value(args, ["column"], "A");
|
||||
const columns = parseList(input, columnNumber);
|
||||
for (const column of [...new Set(columns)].sort((a, b) => b - a)) sheet.spliceColumns(column, 1);
|
||||
await saveWhenRequested(source, args);
|
||||
return columns.length;
|
||||
});
|
||||
|
||||
register("Excel_Cs_ClearRangeV4", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const range = text(args, ["excelRng", "range"]);
|
||||
if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId });
|
||||
const [start, end = start] = range.split(":");
|
||||
const first = sheet.getCell(start);
|
||||
const last = sheet.getCell(end);
|
||||
for (let row = first.row; row <= last.row; row += 1) for (let column = first.col; column <= last.col; column += 1) sheet.getCell(row, column).value = null;
|
||||
await saveWhenRequested(source, args);
|
||||
return range;
|
||||
});
|
||||
|
||||
register("Excel_Cs_MergeCells", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const range = text(args, ["range", "excelRng"]);
|
||||
if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId });
|
||||
if (text(args, ["merge"], "0") === "0") sheet.mergeCells(range); else sheet.unMergeCells(range);
|
||||
await saveWhenRequested(source, args);
|
||||
return range;
|
||||
});
|
||||
|
||||
register("Excel_Cs_SetHeight", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const rows = parseList(value(args, ["rows", "row"], 1), Number);
|
||||
for (const row of rows) sheet.getRow(row).height = Number(value(args, ["height"], 14.5));
|
||||
await saveWhenRequested(source, args);
|
||||
return rows.length;
|
||||
});
|
||||
|
||||
register("Excel_Cs_SetWidth", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const columns = parseList(value(args, ["columns", "column"], "A"), columnNumber);
|
||||
for (const column of columns) sheet.getColumn(column).width = Number(value(args, ["width"], 8.38));
|
||||
await saveWhenRequested(source, args);
|
||||
return columns.length;
|
||||
});
|
||||
|
||||
register("Excel_Cs_CopyV2", async (_ctx, node, args) => {
|
||||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||||
const sheet = worksheetOf(source.workbook, args);
|
||||
const name = text(args, ["newSheet", "name"], `${sheet.name}_copy`);
|
||||
const existing = source.workbook.getWorksheet(name);
|
||||
if (existing) {
|
||||
if (!truthy(args.override)) throw new RuntimeError("validation_error", `Sheet already exists: ${name}`, { nodeId: node.id, componentId: node.componentId });
|
||||
source.workbook.removeWorksheet(existing.id);
|
||||
}
|
||||
const copy = source.workbook.addWorksheet(name);
|
||||
sheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
||||
const target = copy.getRow(rowNumber);
|
||||
target.values = row.values;
|
||||
target.height = row.height;
|
||||
row.eachCell({ includeEmpty: true }, (cell, column) => { target.getCell(column).style = { ...cell.style }; });
|
||||
});
|
||||
sheet.columns.forEach((column, index) => { copy.getColumn(index + 1).width = column.width; });
|
||||
await saveWhenRequested(source, args);
|
||||
return name;
|
||||
});
|
||||
|
||||
function parsePdfPages(raw: string, pageCount: number): number[] {
|
||||
if (!raw.trim()) return Array.from({ length: pageCount }, (_, index) => index);
|
||||
const pages = new Set<number>();
|
||||
for (const value of parseList(raw, Number)) if (value >= 1 && value <= pageCount) pages.add(value - 1);
|
||||
return [...pages];
|
||||
}
|
||||
|
||||
register("PDF_CreateNewPdf", async (_ctx, node, args) => {
|
||||
const sourcePath = requiredPath(value(args, ["fileInPath", "file", "path"]), "PDF path", node.id, node.componentId);
|
||||
const destination = requiredPath(value(args, ["newPdfPath", "output", "outPath"]), "output path", node.id, node.componentId);
|
||||
const outputPath = path.extname(destination).toLowerCase() === ".pdf" ? destination : path.join(destination, `${path.basename(sourcePath, path.extname(sourcePath))}-new.pdf`);
|
||||
const source = await PDFDocument.load(await fs.readFile(sourcePath), { ignoreEncryption: true });
|
||||
const indices = text(args, ["pdfRange"], "all") === "all" ? source.getPageIndices() : parsePdfPages(text(args, ["pdfNum"]), source.getPageCount());
|
||||
if (!indices.length) throw new RuntimeError("validation_error", "No valid PDF pages", { nodeId: node.id, componentId: node.componentId });
|
||||
const target = await PDFDocument.create();
|
||||
for (const page of await target.copyPages(source, indices)) target.addPage(page);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, await target.save());
|
||||
return outputPath;
|
||||
});
|
||||
|
||||
register("PDF_GetPdfInformation", async (_ctx, node, args) => {
|
||||
const filePath = requiredPath(value(args, ["fileInPath", "file", "path"]), "PDF path", node.id, node.componentId);
|
||||
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true, updateMetadata: false });
|
||||
const stat = await fs.stat(filePath);
|
||||
return {
|
||||
path: filePath, fileName: path.basename(filePath), pageCount: document.getPageCount(), size: stat.size,
|
||||
title: document.getTitle() ?? "", author: document.getAuthor() ?? "", subject: document.getSubject() ?? "",
|
||||
creator: document.getCreator() ?? "", producer: document.getProducer() ?? "",
|
||||
creationDate: document.getCreationDate()?.toISOString() ?? "", modificationDate: document.getModificationDate()?.toISOString() ?? ""
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user