From aff2d5cd9514edfe76a69d5840a897210e082088 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Tue, 27 Oct 2026 11:35:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=81=E4=B9=85=E5=8C=96=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E8=AE=B0=E5=BD=95=E5=B9=B6=E8=A1=A5=E5=85=85=E5=88=86?= =?UTF-8?q?=E5=8F=91=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/package.mjs | 113 +++++++++++++++++++ src/main/capabilities/browser.test.ts | 151 ++++++++++++++++++++++++++ src/main/flows.ts | 37 ++++++- src/main/run/manager.ts | 4 +- src/renderer/src/App.tsx | 2 +- src/renderer/src/pages/TasksPage.tsx | 19 +++- src/renderer/src/styles/app.css | 23 ++++ src/shared/types.ts | 2 + 8 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 scripts/package.mjs create mode 100644 src/main/capabilities/browser.test.ts diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100644 index 0000000..546269c --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,113 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import AdmZip from "adm-zip"; +import packager from "@electron/packager"; + +const root = process.cwd(); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); +const version = packageJson.version; +const releaseRoot = path.resolve(root, "release"); +const productFolderName = `RPA-Studio-${version}-win-x64`; +const productFolder = path.join(releaseRoot, productFolderName); +const zipPath = path.join(releaseRoot, `${productFolderName}.zip`); + +function assertInsideRelease(target) { + const relative = path.relative(releaseRoot, path.resolve(target)); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`拒绝操作 release 目录外路径:${target}`); + } +} + +function run(command, args) { + const result = spawnSync(command, args, { cwd: root, stdio: "inherit", shell: process.platform === "win32" }); + if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} 失败,退出码 ${result.status}`); +} + +fs.mkdirSync(releaseRoot, { recursive: true }); +for (const target of [productFolder, zipPath]) { + assertInsideRelease(target); + fs.rmSync(target, { recursive: true, force: true }); +} + +console.log("[package] typecheck"); +run("npm", ["run", "typecheck"]); +console.log("[package] tests"); +run("npm", ["test"]); +console.log("[package] build"); +run("npm", ["run", "build"]); + +console.log("[package] electron packager"); +const outputs = await packager({ + dir: root, + name: "RPA Studio", + platform: "win32", + arch: "x64", + out: releaseRoot, + overwrite: true, + prune: true, + asar: true, + electronVersion: packageJson.devDependencies.electron, + appVersion: version, + win32metadata: { + CompanyName: "RPA Studio", + FileDescription: "RPA Studio Desktop Automation", + ProductName: "RPA Studio", + InternalName: "RPA Studio" + }, + ignore: [ + /^[/\\]\.git($|[/\\])/, + /^[/\\]release($|[/\\])/, + /^[/\\]_analysis($|[/\\])/, + /^[/\\]docs($|[/\\])/, + /^[/\\]scripts($|[/\\])/, + /^[/\\]src($|[/\\])/, + /\.test\.[cm]?[jt]sx?$/ + ] +}); + +const packaged = outputs[0]; +if (!packaged || !fs.existsSync(path.join(packaged, "RPA Studio.exe"))) { + throw new Error(`打包产物无效:${packaged || "未返回目录"}`); +} +if (path.resolve(packaged) !== path.resolve(productFolder)) { + assertInsideRelease(packaged); + fs.renameSync(packaged, productFolder); +} + +const git = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8" }); +const revision = git.status === 0 ? git.stdout.trim() : "unknown"; +const builtAt = new Date().toISOString(); +fs.writeFileSync(path.join(productFolder, "version.json"), JSON.stringify({ + product: "RPA Studio", + version, + revision, + builtAt, + platform: "win32-x64", + electron: packageJson.devDependencies.electron, + distribution: "portable" +}, null, 2)); +fs.writeFileSync(path.join(productFolder, "使用说明.txt"), [ + "RPA Studio Windows x64 免安装版", + "", + "1. 将整个文件夹解压到普通本地目录。", + "2. 双击 RPA Studio.exe 启动。", + "3. 任务、流程和运行记录默认保存在当前 Windows 用户的 AppData\\Roaming\\rpa-studio。", + "4. Word 自动化需要本机安装 Microsoft Word;其他文件级能力无需安装 Office。", + "5. 首次启动如被 Windows SmartScreen 提示,请核对同目录 SHA256SUMS.txt;当前构建未进行商业代码签名。", + "", + `版本:${version}`, + `源码提交:${revision}`, + `构建时间:${builtAt}` +].join("\r\n"), "utf8"); + +console.log("[package] zip"); +const zip = new AdmZip(); +zip.addLocalFolder(productFolder, productFolderName); +zip.writeZip(zipPath); +const sha256 = createHash("sha256").update(fs.readFileSync(zipPath)).digest("hex"); +fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), `${sha256} ${path.basename(zipPath)}\r\n`, "utf8"); + +console.log(JSON.stringify({ productFolder, zipPath, sha256 }, null, 2)); diff --git a/src/main/capabilities/browser.test.ts b/src/main/capabilities/browser.test.ts new file mode 100644 index 0000000..639effb --- /dev/null +++ b/src/main/capabilities/browser.test.ts @@ -0,0 +1,151 @@ +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"; + +const component = {} as CatalogComponent; + +function node(componentId: string): FlowNode { + return { id: `browser_${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, clearFirst?: boolean) => ({ selector, text, clearFirst })), + getText: vi.fn(async () => "fake text"), + exists: vi.fn(async () => true), + select: vi.fn(async (selector: unknown, value: string) => ({ selector, value })), + runJs: vi.fn(async (script: string) => ({ script })), + download: vi.fn(async (_selector: unknown, folder: string, filename?: string) => `${folder}/${filename || "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-test-run", flowId: "browser-test-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("browser capability parameter routing", () => { + let browser: BrowserLike; + let ctx: RunContext; + + beforeEach(() => { browser = fakeBrowser(); ctx = context(browser); }); + + it.each(["WebBrowser_CreateV3", "WebBrowser_Create", "WebBrowser_CreateBackground"])( + "%s routes its URL to browser.navigate", + async (componentId) => { + await expect(invoke(ctx, componentId, { url: "https://offline.test/create" })).resolves.toEqual({ + url: "https://offline.test/create", title: "Fake page" + }); + expect(browser.navigate).toHaveBeenCalledOnce(); + expect(browser.navigate).toHaveBeenCalledWith("https://offline.test/create"); + } + ); + + it("routes navigation URL", async () => { + await invoke(ctx, "WebBrowser_GoUrl", { url: "https://offline.test/next" }); + expect(browser.navigate).toHaveBeenCalledWith("https://offline.test/next"); + }); + + it("routes click selector", async () => { + const selector = { type: "css", value: "#submit" }; + await invoke(ctx, "Element_Click_V6", { uiSelector: selector }); + expect(browser.click).toHaveBeenCalledWith(selector); + }); + + it("routes input selector and text and handles enter/blur", async () => { + const selector = { type: "aria", value: "textbox=Name" }; + await invoke(ctx, "Element_SetValue_V6", { uiSelector: selector, text: "robot", isEnter: "1" }); + expect(browser.type).toHaveBeenCalledWith(selector, "robot", true); + expect(browser.runJs).toHaveBeenCalledWith("document.activeElement && document.activeElement.blur()"); + }); + + it("routes text selector and requested attribute", async () => { + const selector = { type: "css", value: ".result" }; + await expect(invoke(ctx, "Element_GetText_V5", { + uiSelector: selector, attr_type: "attribute", attr_name: "data-result" + })).resolves.toBe("fake text"); + expect(browser.getText).toHaveBeenCalledWith(selector, "data-result"); + }); + + it("routes element-exists selector", async () => { + const selector = { type: "text", value: "Completed" }; + await expect(invoke(ctx, "Element_Exists", { uiSelector: selector })).resolves.toBe(true); + expect(browser.exists).toHaveBeenCalledWith(selector); + }); + + it("routes select selector and itemsToSelect value", async () => { + const selector = { type: "css", value: "select[name=status]" }; + await invoke(ctx, "Element_SelectOperation_v1", { uiSelector: selector, itemsToSelect: "approved" }); + expect(browser.select).toHaveBeenCalledWith(selector, "approved"); + }); + + it.each([ + "WebBrowser_RunJsInIeByTitle", "WebBrowser_RunJsInIeBySelector", "WebBrowser_RunJsInIeBySelector_v1", + "WebBrowser_RunJsInIeBySelector_v2", "WebBrowser_RunJsInIeBySelector_v3", "WebBrowser_RunJsInIeBySelectorGround" + ])("%s routes jsCode to browser.runJs", async (componentId) => { + await invoke(ctx, componentId, { jsCode: "return document.title" }); + expect(browser.runJs).toHaveBeenCalledWith("return document.title"); + }); + + it.each(["WebBrowser_SaveAs", "WebBrowser_SaveAs_V1"])( + "%s routes selector and download options", + async (componentId) => { + const selector = { type: "css", value: "a.download" }; + await expect(invoke(ctx, componentId, { + uiSelector: selector, path: "C:/offline/downloads", filename: "report.csv", timeout: "2500" + })).resolves.toBe("C:/offline/downloads/report.csv"); + expect(browser.download).toHaveBeenCalledWith(selector, "C:/offline/downloads", "report.csv", 2500); + } + ); +}); + +describe("browser capability validation", () => { + let browser: BrowserLike; + let ctx: RunContext; + + beforeEach(() => { browser = fakeBrowser(); ctx = context(browser); }); + + it("rejects a missing navigation URL", async () => { + await expectValidationError(invoke(ctx, "WebBrowser_GoUrl", {})); + expect(browser.navigate).not.toHaveBeenCalled(); + }); + + it("rejects a missing element selector", async () => { + await expectValidationError(invoke(ctx, "Element_Click_V6", {})); + expect(browser.click).not.toHaveBeenCalled(); + }); + + it("rejects a missing JavaScript script", async () => { + await expectValidationError(invoke(ctx, "WebBrowser_RunJsInIeByTitle", {})); + expect(browser.runJs).not.toHaveBeenCalled(); + }); + + it("rejects a missing download directory before starting a download", async () => { + await expectValidationError(invoke(ctx, "WebBrowser_SaveAs", { + uiSelector: { type: "css", value: "a.download" } + })); + expect(browser.download).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/flows.ts b/src/main/flows.ts index 7225964..8f8f643 100644 --- a/src/main/flows.ts +++ b/src/main/flows.ts @@ -16,6 +16,36 @@ function fileOf(id: string): string { return path.join(dir(), `${id}.json`); } +interface PersistedRunSummary { + flowId: string; + status: "succeeded" | "failed" | "cancelled"; + finishedAt?: string; + startedAt?: string; + durationMs?: number; + error?: { reason?: string } | string; +} + +function latestRuns(): Map { + const result = new Map(); + const runsDir = path.join(app.getPath("userData"), "runs"); + if (!fs.existsSync(runsDir)) return result; + for (const entry of fs.readdirSync(runsDir)) { + const file = path.join(runsDir, entry, "run.json"); + if (!fs.existsSync(file)) continue; + try { + const run = JSON.parse(fs.readFileSync(file, "utf8")) as PersistedRunSummary; + if (!run.flowId || !run.status) continue; + const current = result.get(run.flowId); + const time = run.finishedAt || run.startedAt || ""; + const currentTime = current?.finishedAt || current?.startedAt || ""; + if (!current || time > currentTime) result.set(run.flowId, run); + } catch { + // 损坏的单次运行记录不应阻塞任务列表 + } + } + return result; +} + export function createFlow(name = "未命名流程"): FlowDefinition { const now = new Date().toISOString(); const id = `flow_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; @@ -44,11 +74,13 @@ export function createFlow(name = "未命名流程"): FlowDefinition { export function listFlows(): FlowSummary[] { const base = dir(); const rows: FlowSummary[] = []; + const runs = latestRuns(); for (const entry of fs.readdirSync(base)) { if (!entry.endsWith(".json")) continue; try { const flow = JSON.parse(fs.readFileSync(path.join(base, entry), "utf8")) as FlowDefinition; if (!flow?.id || !Array.isArray(flow.nodes)) continue; + const lastRun = runs.get(flow.id); rows.push({ id: flow.id, name: flow.name || "未命名流程", @@ -56,7 +88,10 @@ export function listFlows(): FlowSummary[] { nodeCount: flow.nodes.length, enabled: flow.enabled !== false, triggerLabel: flow.trigger?.label || "手动运行", - lastRunStatus: "never" + lastRunAt: lastRun?.finishedAt || lastRun?.startedAt, + lastRunStatus: lastRun?.status || "never", + lastRunDurationMs: lastRun?.durationMs, + lastRunError: typeof lastRun?.error === "string" ? lastRun.error : lastRun?.error?.reason }); } catch { // 忽略损坏文件,避免一个坏流程阻塞列表 diff --git a/src/main/run/manager.ts b/src/main/run/manager.ts index 4aab5f5..9e392a6 100644 --- a/src/main/run/manager.ts +++ b/src/main/run/manager.ts @@ -47,13 +47,13 @@ export function startRun(flow: FlowDefinition, sender: WebContents): { runId: st log, componentOf: (node) => components.get(node.componentId) }); - fs.writeFileSync(path.join(root, "run.json"), JSON.stringify(result, null, 2), "utf8"); + fs.writeFileSync(path.join(root, "run.json"), JSON.stringify({ ...result, flowId: flow.id }, null, 2), "utf8"); } catch (error) { const message = error instanceof Error ? error.message : String(error); emit({ type: "run.finished", status: "failed", durationMs: 0, error: { code: "unknown", reason: message } }); fs.writeFileSync( path.join(root, "run.json"), - JSON.stringify({ runId, status: "failed", error: message }, null, 2), + JSON.stringify({ runId, flowId: flow.id, status: "failed", error: { reason: message }, finishedAt: new Date().toISOString(), durationMs: 0 }, null, 2), "utf8" ); } finally { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2d063ee..b13ff30 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -240,7 +240,7 @@ export function App() { /> )} {page === "editor" && { setPage("tasks"); void refreshFlows(); }} />} - {page === "runs" && setPage("tasks")} />} + {page === "runs" && { setPage("tasks"); void refreshFlows(); }} />} {page === "settings" && } diff --git a/src/renderer/src/pages/TasksPage.tsx b/src/renderer/src/pages/TasksPage.tsx index bd9dfcc..5275932 100644 --- a/src/renderer/src/pages/TasksPage.tsx +++ b/src/renderer/src/pages/TasksPage.tsx @@ -35,6 +35,14 @@ function formatTime(value: string): string { }); } +function formatDuration(value?: number): string { + if (value === undefined) return ""; + if (value < 1000) return `${value} ms`; + return `${(value / 1000).toFixed(value < 10000 ? 1 : 0)} s`; +} + +const RUN_LABEL = { succeeded: "成功", failed: "失败", cancelled: "已取消", never: "未运行" } as const; + export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRefresh }: TasksPageProps) { const [query, setQuery] = useState(""); const [selectedId, setSelectedId] = useState(flows[0]?.id || null); @@ -99,7 +107,7 @@ export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRe 任务 状态 触发方式 - 最近修改 + 最近运行 @@ -123,7 +131,10 @@ export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRe {flow.enabled === false ? "未启用" : "已启用"} {flow.triggerLabel || "手动运行"} - {formatTime(flow.updatedAt)} + + + {flow.lastRunAt ? formatTime(flow.lastRunAt) : "尚未运行"}{flow.lastRunDurationMs !== undefined ? ` · ${formatDuration(flow.lastRunDurationMs)}` : ""} +