文件
PRA-only-demo/src/main/flows.test.ts
T
yu f95a24458e 新增任务分享/导入功能并修复只读文件保存 EPERM
- flows: writeFlowFile 覆盖写加固(清只读属性->删除后 rename->覆盖写),
  外部复制导入的只读任务文件此前导致 flows:save EPERM 崩溃
- flows: exportFlowToPath/importFlowFromPath, 分享文件为单个 FlowDefinition
  JSON(.rpaflow.json); 导入校验 schemaVersion/nodes/edges, id 冲突时生成
  imported_ 前缀新 id 绝不覆盖既有任务
- IPC: flows:export 弹保存对话框 / flows:import 弹多选打开对话框,
  失败文件逐个汇报不阻塞其余
- preload: flows.export/flows.import; shared: FlowsImportResult 类型
- TasksPage: 头部「导入任务」按钮 + 每行「分享」按钮; App 接线与 toast
- 测试: 导出/导入往返、id 冲突、非法文件原因、只读覆盖共 4 例(7/7 绿)
2026-11-04 17:31:44 +08:00

137 行
6.0 KiB
TypeScript

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