PRA-only-demo/scripts/app-system-tests.mjs

398 lines
26 KiB
JavaScript
Raw Normal View History

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")) })) };
}
// 静态清单供离线校验;真实运行前再与运行时注册清单逐项比较。
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.registered = 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.registered || c.implemented)).map(c => c.id).sort();
const actual = groups.flatMap(g => g.entry.components).sort();
if (groups.length !== 13 || actual.length !== 50 || new Set(actual).size !== 50 || JSON.stringify(actual) !== JSON.stringify(expected)) {
const expectedOnly = expected.filter(id => !actual.includes(id));
const actualOnly = actual.filter(id => !expected.includes(id));
throw new Error(`流程包与当前 50 项验收清单不一致;expectedOnly=${JSON.stringify(expectedOnly)};actualOnly=${JSON.stringify(actualOnly)}`);
}
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 runtime = await evaluate(page, "window.rpa.run.capabilities()");
const registered = new Set(runtime.ids || []);
for (const component of catalog.components) component.registered = registered.has(component.id) || registered.has(component.functionName);
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}。覆盖 50 项验收组件,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; });
}