feat: 扩展网页Office能力并增加运行前检查

This commit is contained in:
yu 2026-10-27 13:06:40 +08:00
parent f9688251ea
commit 21b5186442
12 changed files with 1045 additions and 4 deletions

View File

@ -50,6 +50,6 @@ docs 现状分析、实施方案
## 边界
- 1016 项目录中当前有 275 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器、HTTP、Excel/CSV、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」,运行到该节点会明确失败。
- 1016 项目录中当前有 316 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
- 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。

View File

@ -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/循环/重试/跳过、主出口与异常出口语义、运行记录回写和运行前检查。未通过实际场景验证的变体仍不计入最终交付数量。
---

View 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"]'
}));
});
});

View 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;
});

View File

@ -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";

View 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"
});
});
});

View 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() ?? ""
};
});

View File

@ -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");

View File

@ -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>
);
}

View File

@ -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;

View 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" }));
});
});

View 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;
}