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