import { mkdir, mkdtemp, rm, chmod, writeFile } from "node:fs/promises"; import { writeFileSync, readFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const electronState = vi.hoisted(() => ({ userData: "" })); vi.mock("electron", () => ({ app: { getPath: () => electronState.userData, getVersion: () => "test" } })); import { createDemoFlow, createExampleFlows, createFlow, exportFlowToPath, importFlowFromPath, listFlows, saveFlow } from "./flows"; describe("flow summaries", () => { beforeEach(async () => { electronState.userData = await mkdtemp(path.join(os.tmpdir(), "rpa-flow-summary-")); }); afterEach(async () => { await rm(electronState.userData, { recursive: true, force: true }); }); it("loads the latest persisted run into the task summary", async () => { const flow = saveFlow(createFlow("运行记录任务")); const olderDir = path.join(electronState.userData, "runs", "older"); const latestDir = path.join(electronState.userData, "runs", "latest"); await mkdir(olderDir, { recursive: true }); await mkdir(latestDir, { recursive: true }); await writeFile(path.join(olderDir, "run.json"), JSON.stringify({ flowId: flow.id, status: "succeeded", finishedAt: "2026-10-27T01:00:00.000Z", durationMs: 120 })); await writeFile(path.join(latestDir, "run.json"), JSON.stringify({ flowId: flow.id, status: "failed", finishedAt: "2026-10-27T02:00:00.000Z", durationMs: 456, error: { reason: "测试失败原因" } })); expect(listFlows()).toContainEqual(expect.objectContaining({ id: flow.id, name: "运行记录任务", lastRunStatus: "failed", lastRunAt: "2026-10-27T02:00:00.000Z", lastRunDurationMs: 456, lastRunError: "测试失败原因" })); }); it("creates a connected local browser demo flow", () => { const flow = createDemoFlow("http://127.0.0.1:17863/"); expect(flow.name).toBe("本地网页订单演示"); expect(flow.nodes.map((node) => node.componentId)).toEqual([ "Trigger.Manual", "WebBrowser_CreateV3", "Element_SetValue_V6", "Element_SetValue_V6", "Element_Click_V6", "Element_GetText_V5" ]); expect(flow.edges).toHaveLength(5); expect(flow.nodes.find((node) => node.id === "node_result")?.options.outputVar).toBe("订单结果"); }); it("creates six stable example flows without duplicate ids", () => { const examples = createExampleFlows("http://127.0.0.1:17863/"); expect(examples).toHaveLength(6); expect(new Set(examples.map((flow) => flow.id)).size).toBe(6); expect(examples.every((flow) => flow.nodes.length > 2 && flow.edges.length > 1)).toBe(true); expect(examples.find((flow) => flow.id === "example_error_route")?.edges.some((edge) => edge.sourceHandle === "error")).toBe(true); }); }); describe("flow share export/import", () => { beforeEach(async () => { electronState.userData = await mkdtemp(path.join(os.tmpdir(), "rpa-flow-share-")); }); afterEach(async () => { await rm(electronState.userData, { recursive: true, force: true }); }); it("exports a flow to a share file and re-imports it with a fresh id on conflict", async () => { const flow = saveFlow(createFlow("密钥读取任务")); const shareFile = path.join(electronState.userData, "share", "密钥读取任务.rpaflow.json"); await mkdir(path.dirname(shareFile), { recursive: true }); exportFlowToPath(flow.id, shareFile); expect(JSON.parse(readFileSync(shareFile, "utf8")).id).toBe(flow.id); // id 冲突:再次导入同一文件应生成新 id,不覆盖原任务 const imported = importFlowFromPath(shareFile); expect(imported.id).not.toBe(flow.id); expect(imported.name).toBe("密钥读取任务"); expect(listFlows().filter((item) => item.name === "密钥读取任务")).toHaveLength(2); }); it("keeps the original id when importing a share file whose id is free", () => { const shareFile = path.join(electronState.userData, "手工分享.rpaflow.json"); writeFileSync(shareFile, JSON.stringify({ schemaVersion: 1, id: "manual_task_01", name: "手工分享的任务", description: "", enabled: true, trigger: { type: "manual", label: "手动运行" }, variables: [], nodes: [{ id: "node_start", componentId: "Trigger.Manual", position: { x: 160, y: 120 }, values: {}, options: {} }], edges: [], meta: { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", appVersion: "0.1.0" } }), "utf8"); const imported = importFlowFromPath(shareFile); expect(imported.id).toBe("manual_task_01"); expect(imported.name).toBe("手工分享的任务"); }); it("rejects invalid share files with a readable reason", () => { const badJson = path.join(electronState.userData, "bad.rpaflow.json"); writeFileSync(badJson, "not json", "utf8"); expect(() => importFlowFromPath(badJson)).toThrow(/不是有效的 JSON/); const badSchema = path.join(electronState.userData, "schema.rpaflow.json"); writeFileSync(badSchema, JSON.stringify({ schemaVersion: 99, nodes: [], edges: [] }), "utf8"); expect(() => importFlowFromPath(badSchema)).toThrow(/schemaVersion/); const badStructure = path.join(electronState.userData, "structure.rpaflow.json"); writeFileSync(badStructure, JSON.stringify({ schemaVersion: 1, nodes: "no" }), "utf8"); expect(() => importFlowFromPath(badStructure)).toThrow(/nodes\/edges/); }); it("overwrites a read-only flow file on save (imported file scenario)", async () => { const flow = saveFlow(createFlow("只读任务")); const target = path.join(electronState.userData, "flows", `${flow.id}.json`); // 模拟外部复制进来带只读属性的导入文件 await chmod(target, 0o444); expect(() => saveFlow({ ...flow, name: "只读任务-改" })).not.toThrow(); expect(JSON.parse(readFileSync(target, "utf8")).name).toBe("只读任务-改"); await chmod(target, 0o666); }); });