// 对线上发布站点做功能巡检并采集使用说明书插图。 // // 运行: // PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs \ // node docs/user-manual/capture_deployed_manual.mjs // // 可选环境变量:MANUAL_URL(默认线上 58010 站点)、CHROME_PATH。 // 输出:docs/user-manual/screenshots/*.png 与 docs/user-manual/deployment-test.report.json // // 结构: // 阶段一 在 XYZAC 上跑完整操作链(上电、回零、Jog、主轴、MDI、程序运行等)。 // 阶段二 逐案例探测仿真案例在浏览器中的可用性(每案例独立页面)。 import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const shotDir = resolve(here, "screenshots"); const reportPath = resolve(here, "deployment-test.report.json"); const baseUrl = process.env.MANUAL_URL ?? "https://49.232.250.35:58010/"; const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome"; const playwrightModule = process.env.PLAYWRIGHT_MODULE; const results = []; const consoleErrors = []; const failedRequests = []; const artifacts = []; const defects = []; const caseMatrix = []; await mkdir(shotDir, { recursive: true }); const { chromium } = await import(playwrightModule); const browser = await chromium.launch({ executablePath: chromePath, args: ["--no-sandbox", "--ignore-certificate-errors", "--enable-unsafe-swiftshader"], }); const newPage = async () => { const page = await browser.newPage({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 } }); page.on("pageerror", (error) => consoleErrors.push({ kind: "pageerror", text: String(error) })); page.on("console", (message) => { if (message.type() === "error") consoleErrors.push({ kind: "console", text: message.text() }); }); page.on("requestfailed", (request) => failedRequests.push(`${request.url()} ${request.failure()?.errorText ?? ""}`)); page.on("response", (response) => { if (response.status() >= 400) failedRequests.push(`HTTP ${response.status()} ${response.url()}`); }); return page; }; const page = await newPage(); const step = async (name, body) => { const started = Date.now(); try { const detail = await body(); results.push({ name, status: "pass", ms: Date.now() - started, detail: detail ?? null }); console.log(`PASS ${name}${detail ? ` — ${typeof detail === "string" ? detail : JSON.stringify(detail)}` : ""}`); } catch (error) { results.push({ name, status: "fail", ms: Date.now() - started, detail: String(error?.message ?? error) }); console.log(`FAIL ${name} — ${error?.message ?? error}`); } }; const shot = async (name, { full = false } = {}) => { await page.screenshot({ path: resolve(shotDir, `${name}.png`), fullPage: full }); artifacts.push(`${name}.png`); }; const text = async (selector) => (await page.locator(selector).first().innerText()).replace(/\s+/g, " ").trim(); const statusText = () => text(".machine-status"); const positions = async () => page.locator(".xyzac-position-grid output").allInnerTexts(); const settle = async (ms = 600) => { await page.waitForTimeout(ms); }; const closeDialog = async () => { const dialog = page.locator("dialog[open]").first(); if (!(await dialog.count())) return; const button = dialog.locator('header button[aria-label^="关闭"]').first(); if (await button.count()) await button.click(); else await page.keyboard.press("Escape"); await dialog.waitFor({ state: "hidden", timeout: 5000 }).catch(() => {}); }; const waitFor = async (predicate, timeout, label) => { const deadline = Date.now() + timeout; let last = ""; while (Date.now() < deadline) { last = await predicate(); if (last) return last; await page.waitForTimeout(400); } throw new Error(`${label} 在 ${timeout}ms 内未满足,最后观察:${String(last).slice(0, 160)}`); }; const waitState = async (pattern, timeout = 60000) => { const deadline = Date.now() + timeout; let last = ""; while (Date.now() < deadline) { last = await statusText(); if (pattern.test(last)) return last; await page.waitForTimeout(400); } throw new Error(`状态 ${pattern} 在 ${timeout}ms 内未满足,最后状态:${last.slice(0, 200)}`); }; const waitCase = (timeout = 120000) => waitFor(async () => { const value = (await page.locator(".case-status").innerText()).trim(); return value.startsWith("Program:") ? value : null; }, timeout, "案例程序加载完成"); // 重新载入案例会按设计先给机床下电并复位回零状态,运行程序前需要重新上电并回零。 const ensureReady = async () => { const estop = page.locator('[data-axis-control="toolbar-estop"]'); if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await settle(1500); } const power = page.locator('[data-axis-control="toolbar-power"]'); if ((await power.getAttribute("aria-label"))?.includes("Power on") && !(await power.isDisabled())) { await power.click(); await waitState(/STATE_ON|STATE_IDLE/, 40000); } if (!/Machine sequence HOMED/.test(await statusText())) { await page.locator('[data-axis-control="tab-manual"]').click(); await page.locator('[data-axis-control="manual-home-all"]').click(); await waitState(/Machine sequence HOMED/, 60000); } return statusText(); }; // canLoad 为假时下拉框会拒绝切换,这里按值重试直到状态真正生效。 const selectCase = async (value, timeout = 45000) => { const select = page.locator('[data-axis-control="workspace-case-select"]'); const deadline = Date.now() + timeout; while (Date.now() < deadline) { await select.selectOption(value); await settle(2500); if ((await select.inputValue()) === value) { await waitCase(); await settle(2500); return value; } } throw new Error(`案例切换未生效:${value}`); }; // 冷却液等开关是受 WASM 状态回传驱动的受控复选框,点击后要等状态回传再判断,避免误判失败。 const setToggle = async (selector, target, attempts = 6) => { const box = page.locator(selector); for (let attempt = 0; attempt < attempts; attempt += 1) { if ((await box.isChecked()) === target) return true; await box.click(); await settle(1200); } return (await box.isChecked()) === target; }; // ---------------------------------------------------------------- 阶段一:TRT 主线 await step("打开线上站点并等待 WASM 会话就绪", async () => { await page.goto(baseUrl, { waitUntil: "load", timeout: 90000 }); await page.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 }); await waitState(/STATE_/, 60000); await waitCase(90000); await settle(2500); const cases = await page.locator('[data-axis-control="workspace-case-select"] option').allInnerTexts(); await shot("01-main-overview"); return { title: await page.title(), canvases: await page.locator("canvas").count(), cases: cases.length }; }); await step("状态栏、轴位与实时参数由 LinuxCNC WASM 驱动", async () => { const status = await statusText(); const live = await page.locator("[data-xyzac-parameter-source]").first().getAttribute("data-xyzac-parameter-source"); const axes = await positions(); if (!/Task mode/.test(status)) throw new Error(`状态栏异常:${status}`); if (live !== "linuxcnc-wasm") throw new Error(`实时参数来源不是 WASM:${live}`); if (axes.length < 5) throw new Error(`轴位显示数量不足:${axes.length}`); return { live, axes, status: status.slice(0, 120) }; }); await step("Help 菜单可打开 About 对话框", async () => { await page.locator('[data-axis-control="menu-help"]').click(); await page.locator('[data-axis-control="menu-help-about"]').click(); const dialog = page.locator(".axis-about-dialog"); await dialog.waitFor({ state: "visible", timeout: 5000 }); await shot("02-about-dialog"); const body = (await dialog.innerText()).replace(/\s+/g, " "); await page.locator('[data-axis-control="about-close"]').click(); await dialog.waitFor({ state: "hidden", timeout: 5000 }); return body.slice(0, 120); }); await step("File 菜单把宿主边界项正确置灰", async () => { await page.locator('[data-axis-control="menu-file"]').click(); const states = [ await page.locator('[data-axis-control="menu-file-edit-tool-data"]').isDisabled(), await page.locator('[data-axis-control="menu-file-quit"]').isDisabled(), ]; await shot("03-file-menu"); await page.keyboard.press("Escape"); if (states.some((disabled) => !disabled)) throw new Error(`宿主边界项未置灰:${states}`); return states; }); await step("RTCP 程序库按机型过滤并列出可加载程序", async () => { await page.locator('[data-axis-control="workspace-rtcp-programs"]').click(); const panel = page.locator("dialog.rtcp-program-dialog"); await panel.waitFor({ state: "visible", timeout: 8000 }); await settle(600); await shot("04-rtcp-program-library"); const summary = (await panel.locator(".rtcp-program-summary").innerText()).replace(/\s+/g, " "); const options = await panel.locator('[data-axis-control="rtcp-program-select"] option').count(); const scopes = await panel.locator('[data-axis-control="rtcp-program-scope"] option').allInnerTexts(); await closeDialog(); if (options < 3) throw new Error(`RTCP 程序条目不足:${options}`); return { options, summary, scopes }; }); await step("导入机床模型对话框列出内置示例与下载入口", async () => { await page.locator('[data-axis-control="machine-glb-settings"]').click(); const dialog = page.locator("dialog[open]"); await dialog.waitFor({ state: "visible", timeout: 8000 }); await settle(800); await shot("05-machine-import-dialog"); const downloads = await dialog.locator("a[download]").count(); const summary = (await dialog.innerText()).replace(/\s+/g, " "); await closeDialog(); return { downloads, summary: summary.slice(0, 160) }; }); await step("三维机床管理四个步骤页签可切换", async () => { await page.locator('[data-axis-control="machine-manager-settings"]').click(); const dialog = page.locator("dialog[open]"); await dialog.waitFor({ state: "visible", timeout: 8000 }); await settle(1200); const seen = []; for (const [id, name] of [["structure", "06-machine-manager-structure"], ["motion", "07-machine-manager-motion"], ["calibration", "08-machine-manager-calibration"], ["files", "09-machine-manager-files"]]) { await page.locator(`[data-axis-control="machine-manager-tab-${id}"]`).click(); await settle(900); await shot(name); seen.push((await text(`#machine-panel-${id}`)).slice(0, 70)); } await closeDialog(); return seen; }); await step("机床参数对话框显示旋转中心与轴间偏置字段", async () => { await page.locator('[data-axis-control="geometry-settings"]').click(); const dialog = page.locator("dialog[open]"); await dialog.waitFor({ state: "visible", timeout: 8000 }); await settle(600); await shot("10-geometry-parameters"); const fieldCount = await dialog.locator('input[type="number"]').count(); await closeDialog(); if (fieldCount < 4) throw new Error(`几何参数字段不足:${fieldCount}`); return fieldCount; }); await step("案例切换到 XYZBC 后机型与轴组同步改变", async () => { await selectCase("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"); await shot("11-case-xyzbc"); const axes = await page.locator(".axis-radio span").allInnerTexts(); if (axes.join("") !== "XYZBC") throw new Error(`切换机型后轴组异常:${axes}`); await selectCase("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"); const back = await page.locator(".axis-radio span").allInnerTexts(); if (back.join("") !== "XYZAC") throw new Error(`切回 XYZAC 失败:${back}`); return { axes, back }; }); await step("急停复位并可上电", async () => { const estop = page.locator('[data-axis-control="toolbar-estop"]'); if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await settle(1500); } await page.locator('[data-axis-control="toolbar-power"]').click(); return (await waitState(/STATE_ON|STATE_IDLE/, 40000)).slice(0, 120); }); await step("HOME ALL 完成全部关节回零", async () => { await page.locator('[data-axis-control="manual-home-all"]').click(); const status = await waitState(/Machine sequence HOMED/, 60000); await shot("12-manual-homed"); return status.slice(0, 120); }); await step("连续 Jog:按住 X 正向位置变化,松手停止", async () => { await page.locator('[data-axis-control="manual-axis-x"]').check(); const before = (await positions())[0]; await page.locator('[data-axis-control="manual-jog-positive"]').hover(); await page.mouse.down(); await settle(1800); await shot("13-manual-jog"); await page.mouse.up(); await settle(1500); const after = (await positions())[0]; if (before === after) throw new Error(`连续 Jog 后 X 未变化:${before}`); return { before, after }; }); await step("增量 Jog:0.1000 步长按一次步进", async () => { await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("0.1000"); const before = Number((await positions())[0]); await page.locator('[data-axis-control="manual-jog-negative"]').hover(); await page.mouse.down(); await settle(1200); await page.mouse.up(); await settle(2000); const after = Number((await positions())[0]); await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("Continuous"); if (!(Math.abs(Math.abs(before - after) - 0.1) < 0.04)) throw new Error(`增量步长不符:${before} -> ${after}`); return { before, after }; }); await step("主轴启停与冷却液开关可用", async () => { await page.locator('[data-axis-control="tab-manual"]').click(); await page.locator('[data-axis-control="manual-spindle-cw"]').click(); await settle(2500); const live = await text('[data-axis-feature="AXIS-LIVE-PARAMETERS"]'); const mistOn = await setToggle('[data-axis-control="manual-coolant-mist"]', true); await shot("14-spindle-coolant"); await page.locator('[data-axis-control="manual-spindle-stop"]').click(); const mistOff = await setToggle('[data-axis-control="manual-coolant-mist"]', false); if (!mistOn || !mistOff) throw new Error(`冷却液开关未按预期切换:开=${mistOn} 关=${mistOff}`); return { mistOn, mistOff, live: live.slice(0, 110) }; }); await step("MDI 指令执行并写入历史", async () => { await page.locator('[data-axis-control="tab-mdi"]').click(); await page.locator('[data-axis-control="mdi-command"]').fill("G0 X12.5 Y-4 Z0"); await page.locator('[data-axis-control="mdi-submit"]').click(); await settle(3000); await shot("15-mdi"); const history = await page.locator('[data-axis-control="mdi-history-select"]').first().innerText(); if (!history.includes("G0")) throw new Error(`MDI 历史缺记录:${history}`); return history; }); await step("DRO 视图可切换坐标、显示与单位", async () => { await page.locator('[data-axis-control="tab-dro"]').click(); await page.locator('[data-axis-control="dro-coordinate"]').selectOption("relative"); await page.locator('[data-axis-control="dro-display"]').selectOption("commanded"); await page.locator('[data-axis-control="dro-units"]').selectOption("inch"); await settle(1500); await shot("16-dro"); const rows = await page.locator(".dro-row").count(); const footer = await text(".dro-footer"); await page.locator('[data-axis-control="dro-units"]').selectOption("mm"); await page.locator('[data-axis-control="dro-coordinate"]').selectOption("machine"); await page.locator('[data-axis-control="dro-display"]').selectOption("actual"); await settle(800); return { rows, footer: footer.slice(0, 110) }; }); await step("倍率滑块与预览图层开关可用", async () => { await page.locator('[data-axis-control="override-feed"]').fill("80"); await settle(1500); const label = (await page.locator("section.axis-metrics").innerText()).replace(/\s+/g, " "); const layer = page.locator('[data-axis-control="preview-layer-velocity"]'); await layer.uncheck(); await settle(500); const unchecked = !(await layer.isChecked()); await layer.check(); await shot("17-overrides-layers"); if (!unchecked) throw new Error("预览图层开关无效"); return label.slice(0, 150); }); await step("工具栏『载入精确案例』按钮重新载入当前案例", async () => { await page.locator('[data-axis-control="tab-preview"]').click(); await page.locator('[data-axis-control="toolbar-load-case"]').click(); // 修复前这里会停在 blocked: simulation_case_unknown,永远不会回到 Program: 状态。 const settled = await waitCase(120000); const status = (await page.locator(".case-status").innerText()).trim(); if (/^blocked:/.test(status)) throw new Error(`载入精确案例失败:${status}`); return status.slice(0, 80); }); await step("载入案例程序并运行、暂停、继续、停止", async () => { await ensureReady(); await page.locator('[data-axis-control="workspace-load-boat"]').click(); await waitCase(); await settle(3000); await page.locator('[data-axis-control="toolbar-run"]').click(); const running = await waitState(/STATE_RUNNING|INTERP_READING|INTERP_WAITING/, 60000); await settle(2500); await shot("18-program-running"); let paused = ""; if (await page.locator('[data-axis-control="toolbar-pause-resume"]').isEnabled()) { await page.locator('[data-axis-control="toolbar-pause-resume"]').click(); paused = await waitState(/PAUSED|INTERP_PAUSED/, 30000); await settle(1500); await shot("19-program-paused"); await page.locator('[data-axis-control="toolbar-pause-resume"]').click(); await settle(2500); } await page.locator('[data-axis-control="toolbar-stop"]').click(); await settle(3000); return { running: running.slice(0, 100), paused: paused.slice(0, 100), stopped: (await statusText()).slice(0, 100) }; }); await step("RTCP 程序库『加载到控制器』可用", async () => { await page.locator('[data-axis-control="workspace-rtcp-programs"]').click(); const panel = page.locator("dialog.rtcp-program-dialog"); await panel.waitFor({ state: "visible", timeout: 8000 }); await settle(800); try { const load = panel.locator('[data-axis-control="rtcp-program-load"]'); if (!(await load.isEnabled())) throw new Error("默认主程序当前不可加载"); await load.click(); await panel.locator('output[role="status"]').filter({ hasText: "程序已加载" }).waitFor({ state: "visible", timeout: 60000 }); await shot("22-rtcp-load"); return (await panel.locator('output[role="status"]').innerText()).trim(); } finally { await closeDialog(); await settle(2500); } }); await step("工具栏『打开 G 代码文件』可装载本地 .ngc", async () => { await page.setInputFiles('[data-axis-control="program-file-input"]', "web/react/public/machine-cases/xyzac-trt.ngc"); await waitCase(90000); await settle(2500); return (await page.locator(".case-status").innerText()).trim(); }); await step("工具栏『重载当前程序』可用", async () => { await page.locator('[data-axis-control="toolbar-reload"]').click(); await settle(4000); return (await statusText()).slice(0, 110); }); await step("工具栏视图按钮与清除轨迹可用", async () => { for (const id of ["toolbar-view-z", "toolbar-view-x", "toolbar-view-y", "toolbar-view-p"]) { await page.locator(`[data-axis-control="${id}"]`).click(); await settle(500); } const pressed = await page.locator('[data-axis-control="toolbar-view-p"]').getAttribute("aria-pressed"); await page.locator('[data-axis-control="toolbar-rotate"]').click(); await page.locator('[data-axis-control="toolbar-clear-live-plot"]').click(); await settle(1200); await shot("23-views-cleared"); if (pressed !== "true") throw new Error(`视图按钮未置为按下:${pressed}`); return { pressed }; }); await step("诊断页显示启动资源与门控", async () => { await page.locator('[data-axis-control="tab-diagnostics"]').click(); await settle(1200); await shot("20-diagnostics"); const body = await text("#axis-panel-diagnostics"); if (!/Disabled operations|Startup resources/.test(body)) throw new Error(`诊断页内容异常:${body.slice(0, 100)}`); return body.slice(0, 150); }); await step("快捷键 F1 / F3 / F5 生效", async () => { await page.locator("body").click({ position: { x: 5, y: 5 } }); await page.keyboard.press("F1"); const estop = await waitState(/ESTOP/, 15000); await page.keyboard.press("F1"); await settle(2000); await page.keyboard.press("F3"); await settle(800); const manualSelected = await page.locator('[data-axis-control="tab-manual"]').getAttribute("aria-selected"); await page.keyboard.press("F5"); await settle(800); const mdiSelected = await page.locator('[data-axis-control="tab-mdi"]').getAttribute("aria-selected"); if (manualSelected !== "true" || mdiSelected !== "true") throw new Error(`F3/F5 未切换页签:${manualSelected}/${mdiSelected}`); return { estopTriggered: /ESTOP/.test(estop), manualSelected, mdiSelected }; }); await step("窄视口 390x844 无横向溢出", async () => { await page.setViewportSize({ width: 390, height: 844 }); await settle(2500); await shot("21-mobile-390", { full: true }); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); await page.setViewportSize({ width: 1440, height: 900 }); if (overflow > 2) throw new Error(`出现横向溢出 ${overflow}px`); return { overflow }; }); await page.close(); // ---------------------------------------------------------------- 阶段二:案例矩阵 const MATRIX = [ ["xyzac-trt", "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"], ["xyzbc-trt", "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"], ["xyzab-tdr", "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini"], ["max5-maxkins", "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini"], ["trivkins-xyzya", "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini"], ["hexapod-genhexkins", "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini"], ["scorbot-kins", "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini"], ]; console.log("\n阶段二:案例可用性矩阵(每个案例独立页面)"); for (const [id, entrypoint] of MATRIX) { const probe = await newPage(); const fourXx = []; probe.on("response", (response) => { if (response.status() >= 400) fourXx.push(`${response.status()} ${response.url().replace(baseUrl.replace(/\/$/, ""), "")}`); }); try { await probe.goto(baseUrl, { waitUntil: "load", timeout: 90000 }); await probe.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 }); await probe.waitForFunction(() => document.querySelector(".case-status")?.textContent?.startsWith("Program:"), null, { timeout: 90000 }); await probe.waitForTimeout(4000); const select = probe.locator('[data-axis-control="workspace-case-select"]'); let applied = false; for (let attempt = 0; attempt < 5 && !applied; attempt += 1) { await select.selectOption(entrypoint); await probe.waitForTimeout(2500); applied = (await select.inputValue()) === entrypoint; } await probe.waitForTimeout(20000); const power = probe.locator('[data-axis-control="toolbar-power"]'); const usable = applied && !(await power.isDisabled()); const status = (await probe.locator(".machine-status").innerText()).replace(/\s+/g, " "); caseMatrix.push({ id, applied, power: usable ? "enabled" : `disabled:${await power.getAttribute("title")}`, state: status.slice(0, 90), http_4xx: fourXx.slice(0, 2), verdict: usable ? "usable" : applied ? "loads_but_controller_pending" : "switch_rejected", }); console.log(` ${usable ? "可用 " : "不可用"} ${id.padEnd(20)} ${usable ? "可上电" : status.slice(0, 46)}`); } catch (error) { caseMatrix.push({ id, applied: false, power: "unknown", state: String(error?.message ?? error).slice(0, 120), http_4xx: fourXx.slice(0, 2), verdict: "probe_error" }); console.log(` 探测失败 ${id} — ${String(error?.message ?? error).slice(0, 80)}`); } finally { await probe.close(); } } const pendingCases = caseMatrix.filter((row) => row.verdict !== "usable"); if (pendingCases.length) { defects.push({ id: "DEFECT-02", severity: "high", control: "Simulation case 下拉框中的通用运动学案例", observed: `${pendingCases.map((row) => row.id).join("、")} 载入后控制器停在 PENDING,电源按钮保持禁用(linuxcnc_wasm_snapshot_pending),此时下拉框无法切回其它案例,只能刷新页面恢复`, expected: "选中案例后可上电、回零并运行其干运行程序", root_cause: "通用案例运行链在浏览器侧尚未完成:案例的运动学源文件(如 linuxcnc/src/emc/kinematics/trivkins.c、linuxcnc/src/hal/components/xyzab_tdr_kins.comp)没有随前端产物发布,请求返回 404;且会话启动失败后前端不回滚,卡在 pending", workaround: "当前只使用 XYZAC TRT / XYZBC TRT 两个案例;误选其它案例后刷新浏览器页面", }); } const report = { report_id: "LINUXCNC-WEB-DEPLOYED-MANUAL-TEST", url: baseUrl, checked_at: new Date().toISOString(), // 只有步骤失败才判定 failed;已知缺陷(DEFECT-xx)单独记录,不掩盖功能测试结论。 status: results.every((row) => row.status === "pass") ? "verified" : "failed", passed: results.filter((row) => row.status === "pass").length, failed: results.filter((row) => row.status === "fail").length, results, defects, case_matrix: caseMatrix, console_errors: consoleErrors, http_and_request_failures: failedRequests, screenshots: artifacts, }; await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); console.log(`\n${report.status.toUpperCase()} ${report.passed} passed / ${report.failed} failed → ${reportPath}`); if (defects.length) console.log(`缺陷/限制 ${defects.length} 项:`, defects.map((item) => item.id).join("、")); if (consoleErrors.length) console.log("console/page errors:", JSON.stringify(consoleErrors.slice(0, 3))); await browser.close(); process.exit(report.status === "verified" ? 0 : 1);