2026-11-01 20:06:40 -05:00
|
|
|
|
import fs from "node:fs";
|
|
|
|
|
|
import os from "node:os";
|
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
|
|
import { buildSuite, bundleRoot } from "./app-system-test-flows.mjs";
|
|
|
|
|
|
import { installBundle, upgradeBundle, makeReport, caseResults } from "./app-system-tests.mjs";
|
|
|
|
|
|
import { applyCatalogOverrides } from "./catalog-overrides.mjs";
|
2026-11-03 23:36:03 -05:00
|
|
|
|
import { hasCapability, runFlow } from "@rpa/core";
|
|
|
|
|
|
import "../src/main/capabilities/index.ts";
|
2026-11-01 20:06:40 -05:00
|
|
|
|
import { validateFlow } from "../src/renderer/src/validation/flowValidation.ts";
|
|
|
|
|
|
|
|
|
|
|
|
const containers = new Set(["logic_control_try_catch", "logic_control_if_else"]);
|
|
|
|
|
|
const catalog = JSON.parse(fs.readFileSync("resources/catalog.json", "utf8"));
|
2026-11-01 22:59:14 -05:00
|
|
|
|
const isRegistered = (component) => containers.has(component.id) || hasCapability(component.id) || hasCapability(component.functionName);
|
2026-11-01 20:06:40 -05:00
|
|
|
|
const componentMap = new Map(catalog.components.map(c => [c.id, c]));
|
|
|
|
|
|
const temporary = [];
|
|
|
|
|
|
afterEach(() => { for (const dir of temporary.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
|
|
|
|
|
|
|
|
|
|
|
async function execute(number, change) {
|
|
|
|
|
|
const group = buildSuite()[number - 1];
|
|
|
|
|
|
change?.(group.flow);
|
|
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-appsystem-unit-")); temporary.push(dir);
|
|
|
|
|
|
const logs = [];
|
|
|
|
|
|
const result = await runFlow(group.flow, {
|
|
|
|
|
|
runId: `unit-${group.flow.id}`, evidenceDir: dir, signal: new AbortController().signal,
|
|
|
|
|
|
emit: () => {}, log: (message) => logs.push(message), componentOf: node => componentMap.get(node.componentId)
|
|
|
|
|
|
});
|
|
|
|
|
|
return { group, result, logs };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
describe("应用与系统测试流程包", () => {
|
2026-11-01 22:59:14 -05:00
|
|
|
|
it("覆盖真实注册的全部 50 项,13 组无重复归属,不覆盖备份组件", () => {
|
2026-11-01 20:06:40 -05:00
|
|
|
|
const suite = buildSuite();
|
|
|
|
|
|
expect(suite).toHaveLength(13);
|
|
|
|
|
|
const covered = suite.flatMap(group => group.entry.components).sort();
|
2026-11-01 22:59:14 -05:00
|
|
|
|
const executable = catalog.components.filter(c => c.group === "app" && isRegistered(c)).map(c => c.id).sort();
|
2026-11-01 22:07:27 -05:00
|
|
|
|
expect(covered).toHaveLength(50);
|
|
|
|
|
|
expect(new Set(covered).size).toBe(50);
|
2026-11-01 20:06:40 -05:00
|
|
|
|
expect(covered).toEqual(executable);
|
|
|
|
|
|
for (const { flow, entry } of suite) {
|
|
|
|
|
|
expect(new Set(flow.nodes.map(node => node.id)).size).toBe(flow.nodes.length);
|
|
|
|
|
|
expect(new Set(entry.cases.map(c => c.componentId))).toEqual(new Set(entry.components));
|
|
|
|
|
|
const reached = new Set([flow.nodes[0].id]);
|
|
|
|
|
|
for (let n = 0; n < flow.nodes.length; n++) {
|
|
|
|
|
|
for (const edge of flow.edges) if (reached.has(edge.source)) reached.add(edge.target);
|
|
|
|
|
|
for (const node of flow.nodes) if (reached.has(node.parentId)) reached.add(node.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
expect(reached.size, flow.name).toBe(flow.nodes.length);
|
|
|
|
|
|
expect(entry.cases.every(c => flow.nodes.some(n => n.id === c.targetNodeId && n.componentId === c.componentId))).toBe(true);
|
|
|
|
|
|
expect(JSON.parse(fs.readFileSync(path.join(bundleRoot, entry.file), "utf8"))).toEqual(flow);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("目录修正可重复应用,前端类型选项都有实际判断与转换能力", async () => {
|
|
|
|
|
|
expect(applyCatalogOverrides(structuredClone(catalog))).toEqual(catalog);
|
|
|
|
|
|
for (const id of ["Basic_JudgeDataType", "Basic_DataTypeConversion"]) {
|
|
|
|
|
|
const component = componentMap.get(id);
|
|
|
|
|
|
const options = component.fields.find(field => field.key === "into_type").options;
|
|
|
|
|
|
expect(options.length).toBeGreaterThanOrEqual(4);
|
|
|
|
|
|
for (const option of options) {
|
2026-11-03 23:36:03 -05:00
|
|
|
|
const output = await import("@rpa/core").then(({ getHandler }) => getHandler(id)({}, { id: "type-test", componentId: id }, { val: 42, into_type: option.value }, component));
|
2026-11-01 20:06:40 -05:00
|
|
|
|
if (id === "Basic_JudgeDataType") expect(typeof output).toBe("boolean");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const wake = componentMap.get("ScreenControl_ScreenUnlock");
|
|
|
|
|
|
expect(wake.name).toContain("非登录解锁");
|
|
|
|
|
|
expect(wake.fields).toHaveLength(0);
|
|
|
|
|
|
expect(componentMap.get("System_ScreenSaverShowV1").fields).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("只保留真实参数校验阻塞,不出现断链、未知变量或未接入节点", () => {
|
|
|
|
|
|
const blocked = [];
|
|
|
|
|
|
for (const { flow } of buildSuite()) {
|
|
|
|
|
|
const issues = validateFlow(flow, catalog);
|
|
|
|
|
|
expect(issues.every(issue => issue.code === "required"), flow.name).toBe(true);
|
|
|
|
|
|
if (issues.length) blocked.push(flow.id);
|
|
|
|
|
|
}
|
2026-11-05 21:53:56 -05:00
|
|
|
|
// 12 号(锁屏/解锁)曾经因 Screenlock 的 6 个假必填而阻塞;
|
|
|
|
|
|
// 2026-11-05 参数审计确认实现不读这些字段,已改选填并清空,阻塞随之解除(有意变更)
|
|
|
|
|
|
expect(blocked).toEqual(["test_appsystem_08"]);
|
2026-11-01 20:06:40 -05:00
|
|
|
|
const pidOnly = buildSuite()[0].flow;
|
|
|
|
|
|
const killV1 = pidOnly.nodes.find(n => n.componentId === "App_KillV1");
|
|
|
|
|
|
killV1.values.p_name = "";
|
|
|
|
|
|
expect(validateFlow(pidOnly, catalog).filter(issue => issue.nodeId === killV1.id)).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("真实执行转换流程,显式类型选项下完整核对全部用例", async () => {
|
|
|
|
|
|
const { group, result, logs } = await execute(2);
|
|
|
|
|
|
expect(result.status).toBe("succeeded");
|
|
|
|
|
|
expect(result.variables.failures).toBe(0);
|
|
|
|
|
|
expect(result.variables.blocked).toBe(0);
|
2026-11-01 22:07:27 -05:00
|
|
|
|
expect(group.entry.cases.map(c => result.variables[c.resultVariable][2])).toEqual(Array(11).fill("PASS"));
|
|
|
|
|
|
expect(logs.filter(log => log.startsWith("用例结果:"))).toHaveLength(11);
|
2026-11-01 20:06:40 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("注入错误返回类型时断言会失败,且前面的用例继续留有证据", async () => {
|
|
|
|
|
|
// 只改变内存中的测试副本,不把现有组件缺陷固化成测试的预期行为。
|
|
|
|
|
|
const { result } = await execute(3, flow => {
|
|
|
|
|
|
const target = flow.nodes.find(n => n.componentId === "Basic_JudgeDataType");
|
|
|
|
|
|
target.componentId = "Basic_SetVariable";
|
|
|
|
|
|
target.values = { value: "number" };
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(result.variables.failures).toBe(1);
|
|
|
|
|
|
expect(result.variables.case_12[2]).toBe("FAIL");
|
|
|
|
|
|
expect(result.variables.case_12[5]).toBe("number");
|
|
|
|
|
|
expect(result.variables.case_11[2]).toBe("PASS");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("变量、深复制、配置、流程信息和计数能够真实通过", async () => {
|
|
|
|
|
|
const { result } = await execute(4);
|
|
|
|
|
|
expect(result.status).toBe("succeeded");
|
|
|
|
|
|
expect(result.variables.failures).toBe(0);
|
|
|
|
|
|
expect(result.variables.case_10[5]).toBe(14);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("手动任务默认保护有效,不调用输入法、远程连接或断连操作", async () => {
|
|
|
|
|
|
for (const number of [9, 13]) {
|
|
|
|
|
|
const { group, result } = await execute(number);
|
|
|
|
|
|
expect(result.status).toBe("failed");
|
|
|
|
|
|
expect(result.variables.pending).toBe(group.entry.cases.length);
|
|
|
|
|
|
for (const c of group.entry.cases) {
|
|
|
|
|
|
expect(result.nodes.some(n => n.nodeId === c.targetNodeId)).toBe(false);
|
|
|
|
|
|
expect(result.variables[c.resultVariable][2]).toBe("MANUAL");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("字符串 false 或 true 不会误开启手动保护", async () => {
|
|
|
|
|
|
for (const value of ["false", "true"]) {
|
|
|
|
|
|
const { group, result } = await execute(13, flow => {
|
|
|
|
|
|
flow.nodes.find(n => n.options.outputVar === "manualEnabled").values.value = value;
|
|
|
|
|
|
});
|
|
|
|
|
|
for (const c of group.entry.cases) expect(result.nodes.some(n => n.nodeId === c.targetNodeId)).toBe(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("只新增 13 个任务,重复安装保留用户已有修改且不改无关文件", () => {
|
|
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-appsystem-install-")); temporary.push(dir);
|
|
|
|
|
|
const first = installBundle(dir);
|
|
|
|
|
|
expect(first.installed).toHaveLength(13);
|
|
|
|
|
|
const file = path.join(dir, "flows/test_appsystem_01.json");
|
|
|
|
|
|
fs.writeFileSync(file, "用户自行编辑的内容");
|
|
|
|
|
|
fs.writeFileSync(path.join(dir, "flows/other.json"), "其他任务");
|
|
|
|
|
|
const second = installBundle(dir);
|
|
|
|
|
|
expect(second.installed).toHaveLength(0);
|
|
|
|
|
|
expect(second.skipped).toHaveLength(13);
|
|
|
|
|
|
expect(fs.readFileSync(file, "utf8")).toBe("用户自行编辑的内容");
|
|
|
|
|
|
expect(fs.readFileSync(path.join(dir, "flows/other.json"), "utf8")).toBe("其他任务");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("升级仅覆盖匹配旧模板的测试任务,并保存原文件备份", () => {
|
|
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-appsystem-upgrade-")); temporary.push(dir);
|
|
|
|
|
|
installBundle(dir);
|
|
|
|
|
|
const baseline = path.join(dir, "baseline"), backup = path.join(dir, "backup");
|
|
|
|
|
|
fs.mkdirSync(baseline);
|
|
|
|
|
|
for (const { entry, flow } of buildSuite()) {
|
|
|
|
|
|
const old = { ...flow, description: "旧测试模板" };
|
|
|
|
|
|
fs.writeFileSync(path.join(baseline, entry.file), JSON.stringify(old));
|
|
|
|
|
|
fs.writeFileSync(path.join(dir, "flows", entry.file), JSON.stringify(old));
|
|
|
|
|
|
}
|
|
|
|
|
|
const changed = path.join(dir, "flows/test_appsystem_01.json");
|
|
|
|
|
|
const userFlow = JSON.parse(fs.readFileSync(changed, "utf8")); userFlow.description = "用户已修改";
|
|
|
|
|
|
fs.writeFileSync(changed, JSON.stringify(userFlow));
|
|
|
|
|
|
const result = upgradeBundle(dir, baseline, backup);
|
|
|
|
|
|
expect(result.upgraded).toHaveLength(12);
|
|
|
|
|
|
expect(result.skipped).toContainEqual({ id: "test_appsystem_01", reason: "用户已修改,保留现有版本" });
|
|
|
|
|
|
expect(fs.readFileSync(changed, "utf8")).toBe(JSON.stringify(userFlow));
|
|
|
|
|
|
expect(JSON.parse(fs.readFileSync(path.join(backup, "test_appsystem_02.json"), "utf8")).description).toBe("旧测试模板");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("报告按最差用例聚合,不把任务成功或缺失的记录冒充组件通过", () => {
|
|
|
|
|
|
const group = buildSuite()[0];
|
|
|
|
|
|
const variables = Object.fromEntries(group.entry.cases.map(c => [c.resultVariable, [c.id, c.componentId, "PASS", c.input, c.expected, 42]]));
|
|
|
|
|
|
variables.case_8[2] = "FAIL";
|
|
|
|
|
|
const report = makeReport({ runs: [{ flowId: group.flow.id, status: "succeeded", result: { variables } }] });
|
|
|
|
|
|
expect(report.components.find(c => c.id === "App_GetPid").status).toBe("FAIL");
|
|
|
|
|
|
expect(report.components.find(c => c.id === "Basic_ConvertInt").status).toBe("UNRUN");
|
|
|
|
|
|
expect(report.components.find(c => c.id === "System_PlaySound").status).toBe("MANUAL");
|
|
|
|
|
|
expect(caseResults(group.entry, { status: "BLOCKED" }).every(c => c.status === "BLOCKED")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|