diff --git a/README.md b/README.md index c110466..248f3fe 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,6 @@ docs 现状分析、实施方案 ## 边界 -- 1016 项目录中当前有 275 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器、HTTP、Excel/CSV、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」,运行到该节点会明确失败。 +- 1016 项目录中当前有 316 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。 - 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。 - 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。 diff --git a/docs/02-实施方案.md b/docs/02-实施方案.md index b85fc26..39f7b25 100644 --- a/docs/02-实施方案.md +++ b/docs/02-实施方案.md @@ -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/循环/重试/跳过、主出口与异常出口语义、运行记录回写和运行前检查。未通过实际场景验证的变体仍不计入最终交付数量。 --- diff --git a/src/main/capabilities/browser-extended.test.ts b/src/main/capabilities/browser-extended.test.ts new file mode 100644 index 0000000..31da0e2 --- /dev/null +++ b/src/main/capabilities/browser-extended.test.ts @@ -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): Promise { + const handler = getHandler(componentId); + expect(handler, `${componentId} should be registered`).toBeTypeOf("function"); + return handler!(ctx, node(componentId), args, component); +} + +async function expectValidationError(promise: Promise): Promise { + 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"]' + })); + }); +}); + diff --git a/src/main/capabilities/browser-extended.ts b/src/main/capabilities/browser-extended.ts new file mode 100644 index 0000000..9e78d93 --- /dev/null +++ b/src/main/capabilities/browser-extended.ts @@ -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 { + if (!ctx.browser) ctx.browser = await browserSession(); + return ctx.browser; +} + +function selectorOf(args: Record): 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): void { + if (enabled(args.designated_info)) { + throw new RuntimeError("validation_error", "当前网页能力不支持指定其他网页或窗口对象", { + recommendation: "关闭“指定网页/窗口对象”,使用当前统一运行浏览器" + }); + } +} + +function assertSimpleClick(args: Record): 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): boolean { + const append = args.input_type ?? args.input ?? "0"; + return !enabled(append); +} + +function textOf(args: Record): string { + return String(args.text ?? args.value ?? ""); +} + +async function submitActiveElement(browser: BrowserLike, delayMs: number): Promise { + 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 | 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function waitSettings(componentId: string, args: Record): { + 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 { + 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 { + 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; +}); + diff --git a/src/main/capabilities/index.ts b/src/main/capabilities/index.ts index 15db955..8cf0a9a 100644 --- a/src/main/capabilities/index.ts +++ b/src/main/capabilities/index.ts @@ -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"; diff --git a/src/main/capabilities/office-file-extended.test.ts b/src/main/capabilities/office-file-extended.test.ts new file mode 100644 index 0000000..3ba3b6d --- /dev/null +++ b/src/main/capabilities/office-file-extended.test.ts @@ -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 { + const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-office-file-extended-")); + directories.push(directory); + return directory; +} + +async function run(componentId: string, args: Record): Promise { + 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" + }); + }); +}); diff --git a/src/main/capabilities/office-file-extended.ts b/src/main/capabilities/office-file-extended.ts new file mode 100644 index 0000000..44fecda --- /dev/null +++ b/src/main/capabilities/office-file-extended.ts @@ -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, 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, 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, nodeId: string, componentId: string): Promise { + 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): 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): Promise { + 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(); + 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() ?? "" + }; +}); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b13ff30..233471a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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"); diff --git a/src/renderer/src/pages/EditorPage.tsx b/src/renderer/src/pages/EditorPage.tsx index f101e33..71ba6e3 100644 --- a/src/renderer/src/pages/EditorPage.tsx +++ b/src/renderer/src/pages/EditorPage.tsx @@ -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(null); + const [validationIssues, setValidationIssues] = useState(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 (
@@ -178,6 +192,29 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) { + + {validationIssues && ( +
+
+
+ +
运行前检查{validationIssues.filter((item) => item.severity === "error").length} 个错误,{validationIssues.filter((item) => item.severity === "warning").length} 个警告
+ +
+
+ {validationIssues.map((issue, index) => ( + + ))} +
+
+ + {!validationIssues.some((item) => item.severity === "error") && } +
+
+
+ )}
); } diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index 559c104..f0dd47e 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -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; diff --git a/src/renderer/src/validation/flowValidation.test.ts b/src/renderer/src/validation/flowValidation.test.ts new file mode 100644 index 0000000..bb6218c --- /dev/null +++ b/src/renderer/src/validation/flowValidation.test.ts @@ -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" })); + }); +}); diff --git a/src/renderer/src/validation/flowValidation.ts b/src/renderer/src/validation/flowValidation.ts new file mode 100644 index 0000000..427b63b --- /dev/null +++ b/src/renderer/src/validation/flowValidation.ts @@ -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(); + 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; +}