PRA-only-demo/scripts/app-system-tests.mjs
liuwenxin d489288c8e test(应用与系统): 添加组件验收流程与复测报告
提供 13 组、74 个用例,覆盖 49 项可执行组件。
新增隔离运行、真实界面检查、结果汇总和安全安装脚本。
升级仅覆盖未修改的测试模板,并保留原文件备份。
为手动操作设置保护开关,保留修复前后验证证据。

验证:43 项相关检查、类型检查与构建通过。
2026-11-02 09:06:40 +08:00

391 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import net from "node:net";
import { spawn, execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { projectRoot, bundleRoot, writeSuite } from "./app-system-test-flows.mjs";
const execFileAsync = promisify(execFile);
const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const containers = new Set(["logic_control_if_else", "logic_control_count_for", "logic_control_array_for_v3", "logic_control_dictionary_for_v1", "logic_control_while", "infinite_loop", "logic_control_try_catch"]);
const statusNames = { PASS: "通过", FAIL: "失败", BLOCKED: "阻塞", MANUAL: "待人工确认", UNRUN: "未运行" };
export function readBundle() {
const manifest = JSON.parse(fs.readFileSync(path.join(bundleRoot, "manifest.json"), "utf8"));
return { manifest, groups: manifest.groups.map(entry => ({ entry, flow: JSON.parse(fs.readFileSync(path.join(bundleRoot, entry.file), "utf8")) })) };
}
// 静态清单供离线校验;真实运行前再与 Electron 返回的 implemented 清单逐项比较。
export function staticCatalog() {
const registered = new Set();
const directory = path.join(projectRoot, "src/main/capabilities");
for (const name of fs.readdirSync(directory).filter(name => name.endsWith(".ts") && !name.endsWith(".test.ts"))) {
const source = ts.createSourceFile(name, fs.readFileSync(path.join(directory, name), "utf8"), ts.ScriptTarget.Latest, true);
const visit = node => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "register") {
const first = node.arguments[0];
if (first && ts.isStringLiteral(first)) registered.add(first.text);
if (first && ts.isArrayLiteralExpression(first)) for (const item of first.elements) if (ts.isStringLiteral(item)) registered.add(item.text);
}
ts.forEachChild(node, visit);
};
visit(source);
}
const catalog = JSON.parse(fs.readFileSync(path.join(projectRoot, "resources/catalog.json"), "utf8"));
for (const c of catalog.components) c.implemented = containers.has(c.id) || registered.has(c.id) || registered.has(c.functionName);
return catalog;
}
export async function validator() {
const source = fs.readFileSync(path.join(projectRoot, "src/renderer/src/validation/flowValidation.ts"), "utf8");
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } });
return (await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`)).validateFlow;
}
export async function verifyBundle(catalog = staticCatalog()) {
const { manifest, groups } = readBundle();
const expected = catalog.components.filter(c => c.group === "app" && c.implemented).map(c => c.id).sort();
const actual = groups.flatMap(g => g.entry.components).sort();
if (groups.length !== 13 || actual.length !== 49 || new Set(actual).size !== 49 || JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("流程包与当前 49 项可执行清单不一致,请重新生成并核对覆盖范围。");
const validateFlow = await validator();
const validation = {};
for (const { flow, entry } of groups) {
const nodes = new Map(flow.nodes.map(n => [n.id, n]));
if (nodes.size !== flow.nodes.length || flow.nodes[0].componentId !== "Trigger.Manual") throw new Error(`${flow.id} 节点 ID 重复或缺少入口`);
const covered = new Set(entry.cases.map(c => c.componentId));
if (entry.components.some(id => !covered.has(id)) || covered.size !== entry.components.length) throw new Error(`${flow.id} 用例覆盖不完整`);
for (const c of entry.cases) if (nodes.get(c.targetNodeId)?.componentId !== c.componentId) throw new Error(`${c.id} 目标节点不匹配`);
const reached = new Set([flow.nodes[0].id]);
for (let i = 0; i < nodes.size; i++) {
for (const edge of flow.edges) {
if (!nodes.has(edge.source) || !nodes.has(edge.target)) throw new Error(`${flow.id} 连线指向不存在的节点`);
if (reached.has(edge.source)) reached.add(edge.target);
}
for (const node of flow.nodes) if (reached.has(node.parentId)) reached.add(node.id);
}
if (reached.size !== nodes.size) throw new Error(`${flow.id} 存在断链或孤立节点`);
validation[entry.id] = validateFlow(flow, catalog);
const unexpected = validation[entry.id].filter(i => i.code !== "required");
if (unexpected.length) throw new Error(`${flow.id} 存在意外校验问题:${JSON.stringify(unexpected)}`);
}
return { manifest, groups, validation, executableCount: expected.length };
}
export function installBundle(userData) {
const { groups } = readBundle();
const directory = path.join(path.resolve(userData), "flows");
fs.mkdirSync(directory, { recursive: true });
const installed = [], skipped = [];
for (const { flow } of groups) {
const now = new Date().toISOString();
const target = path.join(directory, `${flow.id}.json`);
try {
fs.writeFileSync(target, JSON.stringify({ ...flow, meta: { ...flow.meta, createdAt: now, updatedAt: now } }, null, 2) + "\n", { flag: "wx" });
installed.push(flow.name);
} catch (error) {
if (error.code !== "EEXIST") throw error;
skipped.push(flow.name);
}
}
return { userData: path.resolve(userData), installed, skipped };
}
/** 仅升级仍与指定旧模板一致的测试任务;用户修改过的内容保持原样。 */
export function upgradeBundle(userData, baselineDirectory, backupDirectory) {
const { groups } = readBundle();
const directory = path.join(path.resolve(userData), "flows");
const normalize = flow => { const { meta, ...content } = flow; return JSON.stringify(content); };
const upgraded = [], skipped = [];
for (const { flow, entry } of groups) {
const target = path.join(directory, entry.file);
const baseline = path.join(path.resolve(baselineDirectory), entry.file);
if (!fs.existsSync(target) || !fs.existsSync(baseline)) { skipped.push({ id: entry.id, reason: "缺少现有任务或旧模板" }); continue; }
let current;
try { current = JSON.parse(fs.readFileSync(target, "utf8")); }
catch { skipped.push({ id: entry.id, reason: "任务文件无法解析,保留原文件" }); continue; }
if (normalize(current) === normalize(flow)) { skipped.push({ id: entry.id, reason: "已是当前版本" }); continue; }
if (normalize(current) !== normalize(JSON.parse(fs.readFileSync(baseline, "utf8")))) { skipped.push({ id: entry.id, reason: "用户已修改,保留现有版本" }); continue; }
fs.mkdirSync(backupDirectory, { recursive: true });
fs.copyFileSync(target, path.join(backupDirectory, entry.file), fs.constants.COPYFILE_EXCL);
const updated = { ...flow, meta: { ...flow.meta, createdAt: current.meta?.createdAt || flow.meta.createdAt, updatedAt: new Date().toISOString() } };
const temp = `${target}.tmp`;
fs.writeFileSync(temp, JSON.stringify(updated, null, 2) + "\n", { flag: "wx" });
fs.renameSync(temp, target);
upgraded.push(entry.id);
}
return { upgraded, skipped, backupDirectory: path.resolve(backupDirectory) };
}
async function freePort() {
const server = net.createServer();
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
const port = server.address().port;
await new Promise(resolve => server.close(resolve));
return port;
}
async function cdpCommand(page, method, params, timeout = 180000) {
const socket = new WebSocket(page.webSocketDebuggerUrl);
let timer;
try {
await new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error("CDP 连接超时")), 10000); socket.onopen = resolve; socket.onerror = reject; });
clearTimeout(timer);
return await new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error(`隔离检查 ${method} 超时`)), timeout);
socket.onerror = reject;
socket.onclose = () => reject(new Error("隔离应用连接提前关闭"));
socket.onmessage = event => {
const response = JSON.parse(event.data);
if (response.id !== 1) return;
if (response.error || response.result?.exceptionDetails) reject(new Error(JSON.stringify(response.error || response.result.exceptionDetails)));
else resolve(response.result);
};
socket.send(JSON.stringify({ id: 1, method, params }));
});
} finally { clearTimeout(timer); socket.close(); }
}
async function evaluate(page, expression, timeout = 180000) {
const response = await cdpCommand(page, "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }, timeout);
return response?.result?.value;
}
async function verifyRenderer(page, output) {
const specs = [
["conversion", "Basic_DataTypeConversion", { val: "42", into_type: "number" }],
["judgment", "Basic_JudgeDataType", { val: 42, into_type: "number" }],
["kill", "App_KillV1", { close_type: "id", pid: "", p_name: "" }],
["command", "System_ExecuteCommandV1", { command_type: "1", dos_command: "echo ui-check" }],
["wake", "ScreenControl_ScreenUnlock", {}]
];
const flow = { schemaVersion: 1, id: "test_appsystem_ui", name: "界面验证:组件参数", variables: [],
nodes: [{ id: "ui_start", componentId: "Trigger.Manual", values: {}, options: {}, position: { x: 220, y: 60 } }, ...specs.map(([id, componentId, values], index) => ({ id: `ui_${id}`, componentId, values, options: {}, position: { x: 220, y: 200 + index * 142 } }))],
edges: [], meta: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), appVersion: "test" } };
await evaluate(page, `window.rpa.flows.save(${JSON.stringify(flow)})`);
await evaluate(page, "document.querySelector('.refresh-btn').click()");
const waitFor = async expression => {
for (let n = 0; n < 100; n++) { if (await evaluate(page, expression)) return; await pause(50); }
throw new Error(`前端状态等待超时:${expression}`);
};
await waitFor(`Array.from(document.querySelectorAll('.task-row')).some(row => row.textContent.includes(${JSON.stringify(flow.name)}))`);
await evaluate(page, `Array.from(document.querySelectorAll('.task-row')).find(row => row.textContent.includes(${JSON.stringify(flow.name)})).querySelector('button[title="编辑流程"]').click()`);
const select = async (id, componentId) => {
await waitFor(`Boolean(document.querySelector('[data-id="ui_${id}"]'))`);
await evaluate(page, `document.querySelector('[data-id="ui_${id}"]').click()`);
await waitFor(`document.querySelector('.props-id')?.textContent === ${JSON.stringify(componentId)}`);
};
const labels = () => evaluate(page, "Array.from(document.querySelectorAll('.props .field-label')).map(el => el.textContent.trim())");
const clickOption = text => evaluate(page, `Array.from(document.querySelectorAll('.props .option-chip')).find(el => el.textContent === ${JSON.stringify(text)}).click()`);
const screenshot = async name => {
const result = await cdpCommand(page, "Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
fs.writeFileSync(path.join(output, name), Buffer.from(result.data, "base64"));
};
const observations = {};
await select("conversion", "Basic_DataTypeConversion");
observations.conversionOptions = await evaluate(page, "Array.from(document.querySelector('.props select').options).map(o => ({label:o.text,value:o.value}))");
if (!["number", "int", "bool", "string"].every(value => observations.conversionOptions.some(o => o.value === value))) throw new Error("转换类型下拉选项缺失");
await screenshot("ui-conversion.png");
await select("judgment", "Basic_JudgeDataType");
observations.judgmentOptions = await evaluate(page, "Array.from(document.querySelector('.props select').options).map(o => o.value)");
if (!["number", "int", "null", "dict", "list", "string", "bool"].every(value => observations.judgmentOptions.includes(value))) throw new Error("判断类型下拉选项缺失");
await select("kill", "App_KillV1");
observations.killPidFields = await labels();
if (!observations.killPidFields.some(s => s.startsWith("进程PID")) || observations.killPidFields.some(s => s.startsWith("进程名称"))) throw new Error("PID 模式显示了错误字段");
await clickOption("按进程名称"); await pause(80);
observations.killNameFields = await labels();
if (!observations.killNameFields.some(s => s.startsWith("进程名称")) || observations.killNameFields.some(s => s.startsWith("进程PID"))) throw new Error("名称模式显示了错误字段");
await screenshot("ui-kill-name.png");
await select("command", "System_ExecuteCommandV1");
observations.commandDosFields = await labels();
if (!observations.commandDosFields.some(s => s.startsWith("DOS命令")) || observations.commandDosFields.some(s => s.startsWith("BAT文件") || s.startsWith("PowerShell命令"))) throw new Error("DOS 模式显示了错误字段");
await clickOption("PowerShell 命令"); await pause(80);
observations.commandPsFields = await labels();
if (!observations.commandPsFields.some(s => s.startsWith("PowerShell命令")) || observations.commandPsFields.some(s => s.startsWith("DOS命令") || s.startsWith("BAT文件"))) throw new Error("PowerShell 模式显示了错误字段");
await screenshot("ui-command-ps.png");
await select("wake", "ScreenControl_ScreenUnlock");
observations.wakeTitle = await evaluate(page, "document.querySelector('.props-title').textContent");
observations.wakeFields = await labels();
if (!observations.wakeTitle.includes("非登录解锁") || observations.wakeFields.some(s => s === "密码" || s === "用户名")) throw new Error("唤醒组件仍在宣称登录解锁或索要凭据");
await screenshot("ui-wake.png");
fs.writeFileSync(path.join(output, "ui-check.json"), JSON.stringify({ passed: true, observations }, null, 2) + "\n");
return observations;
}
export async function runSafe(outputDirectory, checkUi = false) {
const output = path.resolve(outputDirectory);
fs.mkdirSync(output, { recursive: true });
const profile = fs.mkdtempSync(path.join(output, "isolated-"));
const debugPort = await freePort();
const candidates = [process.env.RPA_ELECTRON_DIST, "C:/electron-rpa/dist", path.join(projectRoot, "node_modules/electron/dist")].filter(Boolean);
const dist = candidates.find(dir => fs.existsSync(path.join(dir, "electron.exe")));
if (!dist) throw new Error("找不到 Electron,请先安装项目依赖。");
if (!fs.existsSync(path.join(projectRoot, "out/main/index.js"))) throw new Error("请先执行 npm run build。");
// Chromium 在隐藏窗口中可能停止后续绘制;界面截图检查使用独立可见窗口。
const env = { ...process.env, RPA_E2E: checkUi ? "0" : "1", RPA_SKIP_SINGLE_INSTANCE: "1", RPA_FIXTURE_PORT: "0", RPA_APP_SYSTEM_TEST_VALUE: "隔离环境原始值" };
delete env.ELECTRON_RUN_AS_NODE;
const child = spawn(path.join(dist, "electron.exe"), [projectRoot, `--user-data-dir=${profile}`, `--remote-debugging-port=${debugPort}`, "--remote-debugging-address=127.0.0.1", "--no-sandbox"], { cwd: projectRoot, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
let diagnostics = "";
for (const stream of [child.stdout, child.stderr]) stream.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-16000); });
child.on("error", error => { diagnostics += error.message; });
const runs = [];
try {
let page;
const deadline = Date.now() + 45000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`隔离应用提前退出:${diagnostics}`);
try {
const targets = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`, { signal: AbortSignal.timeout(1500) })).json();
page = targets.find(p => p.type === "page" && p.title === "RPA Studio");
if (page && await evaluate(page, "Boolean(window.rpa)", 5000)) break;
} catch { /* Electron 和 preload 尚未就绪,继续有界等待。 */ }
page = undefined;
await pause(200);
}
if (!page) throw new Error(`等待隔离应用超时:${diagnostics}`);
const info = await evaluate(page, "window.rpa.app.info()");
if (path.resolve(info.userData).toLowerCase() !== profile.toLowerCase()) throw new Error("隔离用户目录不匹配,已拒绝执行测试。");
const catalog = await evaluate(page, "window.rpa.catalog.get()");
const checked = await verifyBundle(catalog);
for (const { flow, entry } of checked.groups) {
const issues = checked.validation[entry.id];
if (entry.manual || issues.some(i => i.severity === "error")) {
const status = issues.some(i => i.severity === "error") ? "BLOCKED" : "MANUAL";
runs.push({ flowId: flow.id, status, validation: issues, reason: status === "BLOCKED" ? "运行前参数校验拦截,未绕过校验;详见校验问题" : "手动任务不纳入自动执行" });
console.log(`${flow.name}:${statusNames[status]}`);
continue;
}
const record = await evaluate(page, `(async () => {
const flow = ${JSON.stringify(flow)};
await window.rpa.flows.save(flow);
const events = [];
const off = window.rpa.run.onEvent(event => events.push(event));
try {
const started = await window.rpa.run.start(flow);
const finished = await new Promise((resolve, reject) => {
const end = Date.now() + 150000;
const timer = setInterval(() => {
const item = events.find(e => e.runId === started.runId && e.type === 'run.finished');
if (item) { clearInterval(timer); resolve(item); }
else if (Date.now() > end) { clearInterval(timer); void window.rpa.run.stop(started.runId); reject(new Error('流程执行超时')); }
}, 50);
});
return { flowId: flow.id, runId: started.runId, status: finished.status, durationMs: finished.durationMs, events };
} finally { off(); }
})()`);
// run.finished 先通过 IPC 发出,主进程随后落盘,等待真正持久化再读取证据。
const persisted = path.join(profile, "runs", record.runId, "run.json");
for (let n = 0; n < 30 && !fs.existsSync(persisted); n++) await pause(100);
if (!fs.existsSync(persisted)) throw new Error(`${record.runId} 没有持久化运行证据`);
record.persistedFile = persisted;
record.result = JSON.parse(fs.readFileSync(persisted, "utf8"));
runs.push(record);
console.log(`${flow.name}:${record.status}(${record.durationMs} ms)`);
}
const report = { generatedAt: new Date().toISOString(), profile, executableCount: checked.executableCount, runs };
fs.writeFileSync(path.join(output, "latest.json"), JSON.stringify(report, null, 2) + "\n");
if (checkUi) {
try { report.ui = { passed: true, observations: await verifyRenderer(page, output) }; }
catch (error) { report.ui = { passed: false, reason: error.message }; throw error; }
finally { fs.writeFileSync(path.join(output, "latest.json"), JSON.stringify(report, null, 2) + "\n"); }
}
return report;
} finally {
if (child.pid && child.exitCode === null) {
try { await execFileAsync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, timeout: 10000 }); }
catch (error) { if (child.exitCode === null) console.warn(`隔离进程退出检查:${error.message}`); }
}
// 保留隔离目录中的 run.json 作为可审阅证据,不清理用户或仓库目录。
}
}
export function caseResults(entry, record) {
return entry.cases.map(c => {
const value = record?.result?.variables?.[c.resultVariable];
return { ...c, status: value?.[2] || (record?.status === "BLOCKED" ? "BLOCKED" : entry.manual ? "MANUAL" : "UNRUN"), actual: value?.[5], reason: c.blockedReason || record?.reason };
});
}
export function makeReport(evidence) {
const { groups } = readBundle();
const records = new Map((evidence.runs || []).map(r => [r.flowId, r]));
const cases = groups.flatMap(g => caseResults(g.entry, records.get(g.entry.id)));
const priority = ["FAIL", "BLOCKED", "MANUAL", "UNRUN", "PASS"];
const catalog = staticCatalog();
const components = new Map(catalog.components.map(c => [c.id, c]));
const rows = groups.flatMap(({ entry }) => entry.components.map(id => {
const matches = cases.filter(c => c.componentId === id);
const status = priority.find(s => matches.some(c => c.status === s)) || "UNRUN";
return { id, name: components.get(id)?.name || id, group: entry.id.slice(-2), status };
}));
const counts = Object.fromEntries(priority.map(s => [s, rows.filter(r => r.status === s).length]));
const escape = value => String(value ?? "无执行记录").replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ").slice(0, 1200);
const recordedAt = evidence.generatedAt ? new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", dateStyle: "medium", timeStyle: "medium", hour12: false }).format(new Date(evidence.generatedAt)) + "(北京时间)" : "未执行";
const lines = ["# 应用与系统组件测试报告", "", `记录时间:${recordedAt}。覆盖 49 项可执行组件,13 组任务。`, "",
`按组件统计:通过 ${counts.PASS},失败 ${counts.FAIL},阻塞 ${counts.BLOCKED},待人工确认 ${counts.MANUAL},未运行 ${counts.UNRUN}。`, "",
"状态依据是断言、前置校验和人工证据,节点执行成功不直接代表功能通过。未执行手动系统操作。", "",
"| 组 | 组件 ID | 名称 | 状态 |", "|---|---|---|---|"];
for (const r of rows) lines.push(`| ${r.group} | ${r.id} | ${r.name} | ${statusNames[r.status]} |`);
if (evidence.ui) lines.push("", "## 真实界面验证", "", evidence.ui.passed ? "已通过独立 Electron 窗口检查:类型下拉选项、PID/名称参数切换、DOS/PowerShell 参数切换,以及唤醒组件不再索要账号密码。界面快照及 ui-check.json 保存在 test-output/app-system/。" : `界面检查未通过:${escape(evidence.ui.reason)}`);
lines.push("", "## 用例证据", "", "| 用例 | 组件 | 状态 | 预期 | 实际/原因 |", "|---|---|---|---|---|");
for (const c of cases) lines.push(`| ${c.id} | ${c.componentId} | ${statusNames[c.status]} | ${escape(c.expected)} | ${escape([c.actual === undefined ? undefined : JSON.stringify(c.actual), c.reason].filter(Boolean).join(";") || undefined)} |`);
lines.push("", "## 运行前校验阻塞", "");
for (const r of evidence.runs || []) {
if (!r.validation?.length) continue;
lines.push(`- ${r.flowId}:${r.validation.map(i => i.message).join(";")}`);
}
lines.push("", "## 证据位置", "", `隔离用户目录:${evidence.profile || "未执行"}`, "");
for (const r of evidence.runs || []) if (r.persistedFile) lines.push(`- ${r.flowId}:${r.persistedFile}`);
return { markdown: lines.join("\n") + "\n", counts, components: rows, cases };
}
function option(name, fallback) {
const index = process.argv.indexOf(name);
if (index < 0) return fallback;
if (!process.argv[index + 1] || process.argv[index + 1].startsWith("--")) throw new Error(`${name} 缺少参数`);
return process.argv[index + 1];
}
async function main() {
const action = process.argv[2];
const output = option("--output", path.join(projectRoot, "test-output/app-system"));
if (action === "generate") { const m = writeSuite(); console.log(`已生成 ${m.groups.length} 组任务。`); }
else if (action === "verify") {
const checked = await verifyBundle();
console.log(JSON.stringify({ groups: checked.groups.length, components: checked.executableCount, validation: checked.validation }, null, 2));
} else if (action === "install") {
await verifyBundle();
const userData = option("--user-data", path.join(process.env.APPDATA || path.join(os.homedir(), "AppData/Roaming"), "rpa-studio"));
console.log(JSON.stringify(installBundle(userData), null, 2));
} else if (action === "upgrade") {
await verifyBundle();
const baseline = option("--baseline", undefined);
if (!baseline) throw new Error("upgrade 需要 --baseline 指定升级前的旧流程模板目录。");
const userData = option("--user-data", path.join(process.env.APPDATA || path.join(os.homedir(), "AppData/Roaming"), "rpa-studio"));
const backup = path.join(output, `installed-backup-${Date.now()}`);
console.log(JSON.stringify(upgradeBundle(userData, baseline, backup), null, 2));
} else if (action === "run-safe") {
await verifyBundle();
const evidence = await runSafe(output, process.argv.includes("--ui-check"));
const report = makeReport(evidence);
fs.writeFileSync(path.join(output, "report.md"), report.markdown);
console.log(JSON.stringify(report.counts));
// 2 表示组件存在失败/阻塞;1 才表示测试工具自身无法完成。
if (report.counts.FAIL || report.counts.BLOCKED) process.exitCode = 2;
} else if (action === "report") {
const evidence = JSON.parse(fs.readFileSync(option("--evidence", path.join(output, "latest.json")), "utf8"));
const report = makeReport(evidence);
const destination = option("--file", path.join(output, "report.md"));
fs.writeFileSync(destination, report.markdown);
console.log(JSON.stringify({ file: destination, counts: report.counts }, null, 2));
} else {
console.log("用法:node scripts/app-system-tests.mjs generate|verify|install|upgrade|run-safe|report\n安装:install --user-data <应用用户目录>\n升级:upgrade --baseline <旧模板目录> --user-data <应用用户目录>\n运行:run-safe --output <证据目录>\n报告:report --evidence <latest.json> --file <报告.md>");
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch(error => { console.error(error.stack || error.message); process.exitCode = 1; });
}