feat: 扩展网页Office能力并增加运行前检查
This commit is contained in:
parent
f9688251ea
commit
21b5186442
@ -50,6 +50,6 @@ docs 现状分析、实施方案
|
||||
|
||||
## 边界
|
||||
|
||||
- 1016 项目录中当前有 275 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器、HTTP、Excel/CSV、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」,运行到该节点会明确失败。
|
||||
- 1016 项目录中当前有 316 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
|
||||
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
|
||||
- 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
> 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认
|
||||
> 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。
|
||||
|
||||
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、数据/文本与二维表格、文件、浏览器、HTTP、Excel/CSV、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **275 项可执行 / 1016 项总目录**。测试覆盖计划能力审计、浏览器参数路由、Excel/PDF/Word/SMTP/模板匹配、文件与二维表格扩展、系统与进程、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写。未通过实际场景验证的变体仍不计入最终交付数量。
|
||||
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、数据/文本与二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **316 项可执行 / 1016 项总目录**。当前 11 个测试文件共 79 条测试,覆盖计划能力审计、浏览器参数路由与变体、Excel/PDF/Word/SMTP/模板匹配、文件与二维表格扩展、系统与进程、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写和运行前检查。未通过实际场景验证的变体仍不计入最终交付数量。
|
||||
|
||||
---
|
||||
|
||||
|
||||
185
src/main/capabilities/browser-extended.test.ts
Normal file
185
src/main/capabilities/browser-extended.test.ts
Normal file
@ -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"]'
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
229
src/main/capabilities/browser-extended.ts
Normal file
229
src/main/capabilities/browser-extended.ts
Normal file
@ -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";
|
||||
|
||||
126
src/main/capabilities/office-file-extended.test.ts
Normal file
126
src/main/capabilities/office-file-extended.test.ts
Normal file
@ -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"
|
||||
});
|
||||
});
|
||||
});
|
||||
256
src/main/capabilities/office-file-extended.ts
Normal file
256
src/main/capabilities/office-file-extended.ts
Normal file
@ -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() ?? ""
|
||||
};
|
||||
});
|
||||
@ -8,6 +8,7 @@ import { RunsPage } from "./pages/RunsPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { TasksPage } from "./pages/TasksPage";
|
||||
import { railIcons } from "./components/icons";
|
||||
import { validateFlow } from "./validation/flowValidation";
|
||||
|
||||
type Page = "tasks" | "editor" | "runs" | "settings";
|
||||
|
||||
@ -169,6 +170,17 @@ export function App() {
|
||||
const runTask = async (id: string) => {
|
||||
try {
|
||||
const flow = await window.rpa.flows.get(id);
|
||||
const issues = validateFlow(flow, catalog);
|
||||
const errors = issues.filter((item) => item.severity === "error");
|
||||
if (errors.length) {
|
||||
loadFlow(flow);
|
||||
setActiveFlowId(id);
|
||||
useEditorStore.getState().selectNode(errors[0].nodeId || null);
|
||||
setPage("editor");
|
||||
showToast(`运行前检查发现 ${errors.length} 个错误,请先修复`, "error");
|
||||
return;
|
||||
}
|
||||
if (issues.length) showToast(`运行前检查有 ${issues.length} 个警告,已按连线执行`);
|
||||
loadFlow(flow);
|
||||
setActiveFlowId(id);
|
||||
setPage("runs");
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowLeft, Check, CirclePlay, Loader2, Redo2, Save, Square, Undo2 } from "lucide-react";
|
||||
import { ArrowLeft, Check, CircleAlert, CirclePlay, Loader2, Redo2, Save, Square, Undo2, X } from "lucide-react";
|
||||
import type { Catalog } from "@shared/types";
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { useRunStore } from "../store/runStore";
|
||||
@ -8,6 +8,7 @@ import { Properties } from "../panels/Properties";
|
||||
import { RunDrawer } from "../panels/RunDrawer";
|
||||
import { FlowCanvas } from "../canvas/FlowCanvas";
|
||||
import { containerSlots } from "../canvas/ContainerNode";
|
||||
import { validateFlow, type FlowValidationIssue } from "../validation/flowValidation";
|
||||
|
||||
interface EditorPageProps {
|
||||
catalog: Catalog;
|
||||
@ -24,6 +25,7 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
||||
const redo = useEditorStore((state) => state.redo);
|
||||
const renameFlow = useEditorStore((state) => state.renameFlow);
|
||||
const markSaved = useEditorStore((state) => state.markSaved);
|
||||
const selectNode = useEditorStore((state) => state.selectNode);
|
||||
const canUndo = useEditorStore((state) => state.past.length > 0);
|
||||
const canRedo = useEditorStore((state) => state.future.length > 0);
|
||||
const running = useRunStore((state) => state.running);
|
||||
@ -31,6 +33,7 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
||||
const stopRun = useRunStore((state) => state.stop);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||
const [validationIssues, setValidationIssues] = useState<FlowValidationIssue[] | null>(null);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const current = useEditorStore.getState().flow;
|
||||
@ -72,7 +75,7 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [redo, save, undo]);
|
||||
|
||||
const runCurrentFlow = useCallback(async () => {
|
||||
const executeCurrentFlow = useCallback(async () => {
|
||||
const current = useEditorStore.getState().flow;
|
||||
if (!current) return;
|
||||
try {
|
||||
@ -84,6 +87,17 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
||||
}
|
||||
}, [markSaved, onToast, startRun]);
|
||||
|
||||
const runCurrentFlow = useCallback(async () => {
|
||||
const current = useEditorStore.getState().flow;
|
||||
if (!current) return;
|
||||
const issues = validateFlow(current, catalog);
|
||||
if (issues.length) {
|
||||
setValidationIssues(issues);
|
||||
return;
|
||||
}
|
||||
await executeCurrentFlow();
|
||||
}, [catalog, executeCurrentFlow]);
|
||||
|
||||
if (!flow) {
|
||||
return (
|
||||
<div className="loading">
|
||||
@ -178,6 +192,29 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
|
||||
</section>
|
||||
|
||||
<Properties catalog={catalog} onToast={onToast} />
|
||||
|
||||
{validationIssues && (
|
||||
<div className="preflight-backdrop" role="dialog" aria-modal="true" aria-label="运行前检查">
|
||||
<section className="preflight-dialog">
|
||||
<header className="preflight-head">
|
||||
<span className="preflight-icon"><CircleAlert className="icon" /></span>
|
||||
<div><strong>运行前检查</strong><span>{validationIssues.filter((item) => item.severity === "error").length} 个错误,{validationIssues.filter((item) => item.severity === "warning").length} 个警告</span></div>
|
||||
<button type="button" className="icon-btn" title="关闭" onClick={() => setValidationIssues(null)}><X className="icon" /></button>
|
||||
</header>
|
||||
<div className="preflight-list">
|
||||
{validationIssues.map((issue, index) => (
|
||||
<button type="button" className={`preflight-item ${issue.severity}`} key={`${issue.code}-${issue.nodeId || index}`} onClick={() => { if (issue.nodeId) selectNode(issue.nodeId); setValidationIssues(null); }}>
|
||||
<span>{issue.severity === "error" ? "错误" : "警告"}</span><strong>{issue.message}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<footer className="preflight-actions">
|
||||
<button type="button" className="btn" onClick={() => setValidationIssues(null)}>返回修改</button>
|
||||
{!validationIssues.some((item) => item.severity === "error") && <button type="button" className="btn btn-primary" onClick={() => { setValidationIssues(null); void executeCurrentFlow(); }}>忽略警告并运行</button>}
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1492,6 +1492,87 @@ textarea:focus-visible {
|
||||
.run-page-log.warn > p { color: var(--warn-deep); }
|
||||
.run-page-log.error > p { color: var(--danger); }
|
||||
|
||||
/* ---------- 运行前检查 ---------- */
|
||||
.preflight-backdrop {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(23, 32, 51, 0.34);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.preflight-dialog {
|
||||
width: min(560px, calc(100vw - 48px));
|
||||
max-height: min(650px, calc(100vh - 48px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.78);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.preflight-head {
|
||||
min-height: 68px;
|
||||
padding: 12px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) 30px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.preflight-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
color: var(--warn-deep);
|
||||
background: var(--warn-bg);
|
||||
}
|
||||
|
||||
.preflight-head > div {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.preflight-head strong { font-size: 14px; }
|
||||
.preflight-head span { color: var(--muted); font-size: 10.5px; }
|
||||
.preflight-list { min-height: 0; overflow: auto; padding: 8px; }
|
||||
|
||||
.preflight-item {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 8px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.preflight-item:hover { background: var(--soft); }
|
||||
.preflight-item > span { font-size: 9px; font-weight: 750; text-align: center; padding: 2px 4px; border-radius: 4px; }
|
||||
.preflight-item.error > span { color: var(--danger-deep); background: var(--danger-bg); }
|
||||
.preflight-item.warning > span { color: var(--warn-deep); background: var(--warn-bg); }
|
||||
.preflight-item strong { overflow: hidden; font-size: 11px; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.preflight-actions {
|
||||
padding: 11px 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.empty {
|
||||
height: 100%;
|
||||
min-height: 180px;
|
||||
|
||||
37
src/renderer/src/validation/flowValidation.test.ts
Normal file
37
src/renderer/src/validation/flowValidation.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Catalog, FlowDefinition } from "@shared/types";
|
||||
import { validateFlow } from "./flowValidation";
|
||||
|
||||
const catalog = {
|
||||
schemaVersion: 1, generatedAt: "", counts: { total: 2, vendor: 1, native: 0, triggers: 1 }, groups: [],
|
||||
components: [
|
||||
{ id: "Trigger.Manual", name: "人工发起", kind: "trigger", group: "trigger", fields: [], slots: [], implemented: true },
|
||||
{ id: "Test.Action", name: "测试动作", kind: "vendor", group: "data", fields: [{ key: "value", label: "内容", required: true, widget: "text", defaultValue: "" }], slots: [], implemented: false }
|
||||
]
|
||||
} as unknown as Catalog;
|
||||
|
||||
function flow(): FlowDefinition {
|
||||
const now = new Date().toISOString();
|
||||
return { schemaVersion: 1, id: "test", name: "test", variables: [], nodes: [
|
||||
{ id: "start", componentId: "Trigger.Manual", position: { x: 0, y: 0 }, values: {}, options: {} },
|
||||
{ id: "action", componentId: "Test.Action", position: { x: 0, y: 100 }, values: {}, options: {} },
|
||||
{ id: "orphan", componentId: "Trigger.Manual", position: { x: 200, y: 100 }, values: {}, options: {} }
|
||||
], edges: [{ id: "edge", source: "start", target: "action" }], meta: { createdAt: now, updatedAt: now, appVersion: "test" } };
|
||||
}
|
||||
|
||||
describe("flow validation", () => {
|
||||
it("reports pending components, required fields, and disconnected nodes", () => {
|
||||
const issues = validateFlow(flow(), catalog);
|
||||
expect(issues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ code: "not_implemented", nodeId: "action" }),
|
||||
expect.objectContaining({ code: "required", nodeId: "action" }),
|
||||
expect.objectContaining({ code: "disconnected", nodeId: "orphan", severity: "warning" })
|
||||
]));
|
||||
});
|
||||
|
||||
it("rejects multiple normal exits", () => {
|
||||
const definition = flow();
|
||||
definition.edges.push({ id: "second", source: "start", target: "orphan" });
|
||||
expect(validateFlow(definition, catalog)).toContainEqual(expect.objectContaining({ code: "multiple_normal_edges", nodeId: "start" }));
|
||||
});
|
||||
});
|
||||
76
src/renderer/src/validation/flowValidation.ts
Normal file
76
src/renderer/src/validation/flowValidation.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { Catalog, FlowDefinition } from "@shared/types";
|
||||
|
||||
export interface FlowValidationIssue {
|
||||
severity: "error" | "warning";
|
||||
code: string;
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
function empty(value: unknown): boolean {
|
||||
return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
|
||||
}
|
||||
|
||||
export function validateFlow(flow: FlowDefinition, catalog: Catalog): FlowValidationIssue[] {
|
||||
const issues: FlowValidationIssue[] = [];
|
||||
const componentMap = new Map(catalog.components.map((component) => [component.id, component]));
|
||||
const nodeIds = new Set(flow.nodes.map((node) => node.id));
|
||||
const topLevel = flow.nodes.filter((node) => !node.parentId);
|
||||
|
||||
if (!flow.nodes.length) issues.push({ severity: "error", code: "empty_flow", message: "任务中没有任何节点" });
|
||||
|
||||
for (const node of flow.nodes) {
|
||||
const component = componentMap.get(node.componentId);
|
||||
if (!component) {
|
||||
issues.push({ severity: "error", code: "missing_component", nodeId: node.id, message: `组件不存在:${node.componentId}` });
|
||||
continue;
|
||||
}
|
||||
if (!component.implemented) {
|
||||
issues.push({ severity: "error", code: "not_implemented", nodeId: node.id, message: `「${node.label || component.name}」尚未接入执行器` });
|
||||
}
|
||||
for (const field of component.fields.filter((item) => item.required && item.widget !== "hidden" && item.widget !== "region")) {
|
||||
const current = Object.prototype.hasOwnProperty.call(node.values, field.key) ? node.values[field.key] : field.defaultValue;
|
||||
if (empty(current)) issues.push({ severity: "error", code: "required", nodeId: node.id, message: `「${node.label || component.name}」缺少必填参数:${field.label}` });
|
||||
}
|
||||
if (node.parentId && !nodeIds.has(node.parentId)) {
|
||||
issues.push({ severity: "error", code: "missing_parent", nodeId: node.id, message: `「${node.label || component.name}」所属容器不存在` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of flow.edges) {
|
||||
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
||||
issues.push({ severity: "error", code: "dangling_edge", message: `连线 ${edge.id} 指向不存在的节点` });
|
||||
}
|
||||
}
|
||||
|
||||
if (flow.edges.length && topLevel.length) {
|
||||
const topIds = new Set(topLevel.map((node) => node.id));
|
||||
const validEdges = flow.edges.filter((edge) => topIds.has(edge.source) && topIds.has(edge.target));
|
||||
for (const node of topLevel) {
|
||||
const normal = validEdges.filter((edge) => edge.source === node.id && edge.sourceHandle !== "error");
|
||||
const errors = validEdges.filter((edge) => edge.source === node.id && edge.sourceHandle === "error");
|
||||
if (normal.length > 1) issues.push({ severity: "error", code: "multiple_normal_edges", nodeId: node.id, message: "当前执行器不支持一个节点连接多条主流程出口" });
|
||||
if (errors.length > 1) issues.push({ severity: "error", code: "multiple_error_edges", nodeId: node.id, message: "一个节点只能连接一条异常出口" });
|
||||
}
|
||||
|
||||
const incoming = new Set(validEdges.map((edge) => edge.target));
|
||||
const start = topLevel.find((node) => !incoming.has(node.id) && node.componentId.startsWith("Trigger.")) || topLevel.find((node) => !incoming.has(node.id));
|
||||
if (!start) issues.push({ severity: "error", code: "no_start", message: "流程没有可识别的起点,可能存在循环连线" });
|
||||
else {
|
||||
const reachable = new Set<string>();
|
||||
const queue = [start.id];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
if (reachable.has(current)) continue;
|
||||
reachable.add(current);
|
||||
for (const edge of validEdges.filter((item) => item.source === current)) queue.push(edge.target);
|
||||
}
|
||||
for (const node of topLevel.filter((item) => !reachable.has(item.id))) {
|
||||
const component = componentMap.get(node.componentId);
|
||||
issues.push({ severity: "warning", code: "disconnected", nodeId: node.id, message: `「${node.label || component?.name || node.componentId}」未连接到主流程,不会被执行` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user