import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import puppeteer from "puppeteer-core"; import { PNG } from "pngjs"; const REPO_ROOT = path.resolve("/home/meswork/cnc_wams"); const ROOT = path.resolve("/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test"); const OUTPUT_DIR = path.join(ROOT, "output"); const SCREENSHOT_DIR = path.join(ROOT, "screenshots"); const TARGET_URL = process.env.TARGET_URL || ""; const APP_URL = process.env.APP_URL || "/web-rtcp-5axis-sim-plan/app/index.html"; const CHROME_PATH = process.env.CHROME_PATH || process.env.CHROMIUM || "/usr/bin/google-chrome"; const localProgramPath = path.join(ROOT, "fixtures", "test-program.ngc"); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const MIME_TYPES = { ".css": "text/css; charset=utf-8", ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".png": "image/png", ".svg": "image/svg+xml", ".wasm": "application/wasm", ".xml": "application/xml; charset=utf-8", }; function contentTypeFor(filePath) { return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream"; } function createStaticServer(rootDir) { return http.createServer(async (request, response) => { try { const requestPath = decodeURIComponent(new URL(request.url || "/", "http://127.0.0.1").pathname); const relativePath = requestPath === "/" ? "/index.html" : requestPath; const targetPath = path.resolve(rootDir, `.${relativePath}`); if (!targetPath.startsWith(rootDir)) { response.writeHead(403); response.end("forbidden"); return; } let stat = await fs.stat(targetPath).catch(() => null); let filePath = targetPath; if (stat?.isDirectory()) { filePath = path.join(targetPath, "index.html"); stat = await fs.stat(filePath).catch(() => null); } if (!stat?.isFile()) { response.writeHead(404); response.end("not found"); return; } const body = await fs.readFile(filePath); response.writeHead(200, { "Content-Type": contentTypeFor(filePath), "Content-Length": String(body.byteLength), "Cache-Control": "no-store", }); response.end(body); } catch (error) { response.writeHead(500); response.end(error instanceof Error ? error.message : String(error)); } }); } await fs.mkdir(OUTPUT_DIR, { recursive: true }); await fs.mkdir(SCREENSHOT_DIR, { recursive: true }); await fs.mkdir(path.dirname(localProgramPath), { recursive: true }); const findings = []; const consoleLogs = []; const pageErrors = []; const requestFailures = []; let server = null; let targetUrl = TARGET_URL; if (!targetUrl) { server = createStaticServer(REPO_ROOT); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") { throw new Error("failed to start local static server"); } targetUrl = `http://127.0.0.1:${address.port}${APP_URL}`; } const browser = await puppeteer.launch({ headless: true, executablePath: CHROME_PATH, defaultViewport: { width: 1600, height: 1200, deviceScaleFactor: 1 }, ignoreHTTPSErrors: true, args: [ "--ignore-certificate-errors", "--disable-gpu", "--enable-webgl", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--no-sandbox", ], }); let page; try { page = await browser.newPage(); page.on("console", async (msg) => { let text = msg.text(); if (msg.type() === "error" && msg.args().length > 0) { try { const values = await Promise.all(msg.args().map((arg) => arg.jsonValue().catch(() => null))); const serialized = values.filter((value) => value !== null); if (serialized.length > 0) { text = `${text} ${JSON.stringify(serialized)}`; } } catch {} } consoleLogs.push({ type: msg.type(), text, location: msg.location(), }); }); page.on("pageerror", (error) => { pageErrors.push({ message: error.message, stack: error.stack || "", }); }); page.on("requestfailed", (request) => { requestFailures.push({ url: request.url(), method: request.method(), errorText: request.failure()?.errorText || "unknown", resourceType: request.resourceType(), }); }); await fs.writeFile(localProgramPath, [ "%", "G90 G17 G21", "G0 X0 Y0 Z5", "G1 Z-1 F200", "G1 X10 Y10 F300", "M30", "%", "", ].join("\n"), "utf8"); await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 60000 }); await page.waitForSelector('[data-shell="gmoccapy-5axis"]', { timeout: 15000 }); await page.waitForFunction(() => Boolean(window.webRtcp5AxisSimulation?.getState), { timeout: 15000 }); await page.waitForFunction(() => { const state = window.webRtcp5AxisSimulation?.getState?.(); return Boolean(state?.iniConfigReadiness?.loaded); }, { timeout: 20000 }).catch(() => {}); await waitForAppIdle(20000); await waitForState( (state) => state.taskHalRuntimeReadiness?.halSyncReady === true, 20000, "task/HAL runtime readiness", ).catch(() => null); const screenshots = {}; async function capture(name, clipSelector = null) { const targetPath = path.join(SCREENSHOT_DIR, `${name}.png`); if (clipSelector) { const element = await page.$(clipSelector); if (element) { await element.screenshot({ path: targetPath }); } else { await page.screenshot({ path: targetPath, fullPage: true }); } } else { await page.screenshot({ path: targetPath, fullPage: true }); } screenshots[name] = targetPath; return targetPath; } async function getState() { return page.evaluate(() => { const state = window.webRtcp5AxisSimulation.getState(); return JSON.parse(JSON.stringify(state)); }); } async function waitForState(predicate, timeoutMs = 10000, label = "state condition") { const start = Date.now(); while (Date.now() - start < timeoutMs) { const snapshot = await getState(); if (predicate(snapshot)) return snapshot; await sleep(100); } throw new Error(`Timed out waiting for ${label}`); } async function waitForAppIdle(timeoutMs = 8000) { await waitForState( (state) => !state.taskHalExecutionPending && !state.interpreterExecutionPending, timeoutMs, "app idle", ).catch(() => null); } async function waitForTaskHalReady(timeoutMs = 30000) { return waitForState((state) => ( state.taskHalRuntimeReadiness?.loaded === true && state.taskHalRuntimeReadiness?.taskRuntimeReady === true && state.taskHalRuntimeReadiness?.motionRuntimeReady === true && state.taskHalRuntimeReadiness?.halRuntimeReady === true && state.taskHalRuntimeReadiness?.halSyncReady === true && !state.taskHalExecutionPending && !state.interpreterExecutionPending ), timeoutMs, "Task/HAL ready and app idle").catch(() => null); } async function clickJogAndWait(action, axis, direction, beforeValue) { const selector = `[data-action="${action}"]`; await waitForTaskHalReady(30000); for (let attempt = 0; attempt < 2; attempt += 1) { await clickAndWait(selector, 900); const changed = await waitForState((nextState) => { const nextValue = Number(nextState.axisPose?.[axis]); return direction > 0 ? nextValue > Number(beforeValue) : nextValue < Number(beforeValue); }, 6000, `${action} axis change`).catch(() => null); if (changed) return changed; } return getState(); } async function getSummary() { return page.evaluate(() => { const state = window.webRtcp5AxisSimulation.getState(); const regions = window.webRtcp5AxisSimulation.getRegions?.() || null; const machineSummary = document.querySelector('[data-machine-state="summary"]')?.textContent?.trim() || ""; const frameBoundary = document.querySelector('[data-rtcp-diagnostic="boundary"]')?.textContent?.trim() || ""; const boundaryReady = document.querySelector('[data-linuxcnc-boundary="readiness"]')?.textContent?.trim() || ""; const taskHal = document.querySelector('[data-task-hal-runtime="readiness"]')?.textContent?.trim() || ""; const machineFiles = document.querySelector('[data-machine-file-staging="status"]')?.textContent?.trim() || ""; const activeLine = document.querySelector('[data-active-program-line]')?.textContent?.trim() || ""; const linuxCncSourceStatus = document.querySelector('[data-linuxcnc-gcode-source="status"]')?.textContent?.trim() || ""; const canvas = document.querySelector("[data-five-axis-canvas]"); return { regions, machineSummary, frameBoundary, boundaryReady, taskHal, machineFiles, activeLine, linuxCncSourceStatus, canvas: canvas ? { ...canvas.dataset } : null, state, }; }); } async function recordResult(key, title, expectation, actual, status, detail = "") { findings.push({ key, title, expectation, actual, status, detail, }); } async function clickAndWait(selector, waitMs = 600) { await page.waitForSelector(selector, { timeout: 10000 }); await page.waitForFunction((targetSelector) => { const element = document.querySelector(targetSelector); if (!element) return false; const ariaDisabled = element.getAttribute("aria-disabled") === "true"; const commandReady = element.getAttribute("data-command-ready") === "false"; return !element.disabled && !ariaDisabled && !commandReady; }, { timeout: 15000 }, selector); await page.evaluate((targetSelector) => { const element = document.querySelector(targetSelector); if (!element) { throw new Error(`missing click target ${targetSelector}`); } if (element.disabled || element.getAttribute("aria-disabled") === "true") { throw new Error(`disabled click target ${targetSelector}`); } element.scrollIntoView({ block: "center", inline: "center" }); element.click(); }, selector); await sleep(waitMs); await waitForAppIdle(); } async function setInputValue(selector, value) { await page.waitForSelector(selector, { timeout: 10000 }); await page.$eval(selector, (el, nextValue) => { el.value = nextValue; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); }, value); await sleep(400); } async function selectValue(selector, value) { await page.waitForSelector(selector, { timeout: 10000 }); await page.evaluate((targetSelector, nextValue) => { const element = document.querySelector(targetSelector); if (!element) { throw new Error(`missing select target ${targetSelector}`); } element.value = nextValue; element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); }, selector, value); await sleep(400); await waitForAppIdle(); } function classify(condition, passText, failText) { return condition ? { status: "PASS", text: passText } : { status: "FAIL", text: failText }; } function classifyWarn(condition, passText, warnText) { return condition ? { status: "PASS", text: passText } : { status: "WARN", text: warnText }; } const initial = await getSummary(); await capture("01-home"); const regionsPass = [ "titlebar", "preview", "dro", "gcode", "status-sidebar", "info-tabs", "override", "spindle-coolant", "bottom-controls", ].every((region) => initial.regions?.[region] === true); const regionsStatus = classify(regionsPass, "9个主区域全部渲染", "存在主区域未渲染"); await recordResult( "layout-regions", "主界面九大区域渲染", "titlebar/preview/dro/gcode/sidebar/info/override/spindle/bottom 全部存在", regionsStatus.text, regionsStatus.status, ); const canvasFallback = initial.canvas?.threeRenderer === "2d-fallback"; const canvasReady = initial.canvas?.threeReady === "true"; const canvasStatus = canvasReady ? (canvasFallback ? { status: "WARN", text: `预览可用,但当前测试环境使用 ${initial.canvas.threeRenderer},原因:${initial.canvas.threeFallbackReason || "-"}` } : { status: "PASS", text: `预览已启用 ${initial.canvas.threeRenderer}` }) : { status: "FAIL", text: "预览画布未进入 ready 状态" }; await recordResult( "preview-canvas", "预览画布初始化", "预览区域可渲染机床与路径", canvasStatus.text, canvasStatus.status, initial.canvas ? JSON.stringify(initial.canvas) : "missing canvas dataset", ); const initialBoundaryStatus = classify( initial.frameBoundary.includes("linuxcnc_kinematics_wasm_c_abi"), `RTCP 帧边界已接入 LinuxCNC 运动学:${initial.frameBoundary}`, `RTCP 帧仍处于降级边界:${initial.frameBoundary}`, ); await recordResult( "initial-boundary", "首屏 RTCP/运动学边界", "首屏应优先使用 LinuxCNC/WASM 运动学边界", initialBoundaryStatus.text, initialBoundaryStatus.status, ); const taskHalReadyState = await waitForTaskHalReady(30000); const initialTaskHalStatus = classifyWarn( Boolean(taskHalReadyState), `Task/HAL 已就绪:${taskHalReadyState?.taskHalRuntimeReadiness?.semanticBoundary || initial.taskHal}`, `Task/HAL 冷启动时尚未完成就绪:${initial.taskHal}`, ); await recordResult( "initial-task-hal", "首屏 Task/HAL 运行态", "Task/HAL readiness 应就绪", initialTaskHalStatus.text, initialTaskHalStatus.status, ); await clickAndWait('[data-action="power"]'); await waitForState((nextState) => nextState.machine.powerOn === true, 8000, "machine power on").catch(() => null); let state = await getState(); await recordResult( "power-on", "POWER 上电", "点击 POWER 后 machine.powerOn=true,taskState=on", `powerOn=${state.machine.powerOn}, taskState=${state.machine.taskState}, runState=${state.runState}`, state.machine.powerOn && state.machine.taskState === "on" ? "PASS" : "FAIL", ); await clickAndWait('[data-action="mode-jog"]'); await waitForState((nextState) => nextState.machine.mode === "manual", 8000, "JOG/manual mode").catch(() => null); state = await getState(); await recordResult( "mode-jog", "JOG 模式切换", "点击 JOG 后 mode 归一到 manual", `mode=${state.machine.mode}`, state.machine.mode === "manual" ? "PASS" : "FAIL", ); await clickAndWait('[data-action="HOME"]'); await waitForState((nextState) => nextState.machine.allHomed === true, 8000, "machine homed").catch(() => null); await waitForTaskHalReady(30000); state = await getState(); await recordResult( "home", "HOME 回参考点", "点击 HOME 后 allHomed=true,runState=idle", `allHomed=${state.machine.allHomed}, runState=${state.runState}`, state.machine.allHomed && state.runState === "idle" ? "PASS" : "FAIL", ); const beforeJogX = state.axisPose.x; state = await clickJogAndWait("JOG_X_POS", "x", 1, beforeJogX); await recordResult( "jog-x-plus", "JOG X+", "点击 X+ 后 X 坐标增加", `before=${beforeJogX}, after=${state.axisPose.x}`, state.axisPose.x > beforeJogX ? "PASS" : "FAIL", ); const beforeJogY = state.axisPose.y; state = await clickJogAndWait("JOG_Y_NEG", "y", -1, beforeJogY); await recordResult( "jog-y-minus", "JOG Y-", "点击 Y- 后 Y 坐标减小", `before=${beforeJogY}, after=${state.axisPose.y}`, state.axisPose.y < beforeJogY ? "PASS" : "FAIL", ); await clickAndWait('[data-action="mode-auto"]'); await waitForState((nextState) => nextState.machine.mode === "auto", 8000, "AUTO mode").catch(() => null); state = await getState(); await recordResult( "mode-auto", "AUTO 模式切换", "点击 AUTO 后 machine.mode=auto", `mode=${state.machine.mode}`, state.machine.mode === "auto" ? "PASS" : "FAIL", ); await clickAndWait('[data-action="mode-manual"]'); await waitForState((nextState) => nextState.machine.mode === "manual" && nextState.machine.interpState === "idle", 10000, "MANUAL idle before MDI").catch(() => null); await waitForTaskHalReady(30000); await clickAndWait('[data-action="mode-mdi"]'); await waitForState((nextState) => nextState.machine.mode === "mdi", 8000, "MDI mode").catch(() => null); state = await getState(); await recordResult( "mode-mdi", "MDI 模式切换", "点击 MDI 后 machine.mode=mdi", `mode=${state.machine.mode}`, state.machine.mode === "mdi" ? "PASS" : "FAIL", ); if (state.machine.mode !== "mdi") { await clickAndWait('[data-action="mode-manual"]'); await waitForState((nextState) => nextState.machine.mode === "manual", 10000, "retry manual before MDI").catch(() => null); await waitForTaskHalReady(30000); await clickAndWait('[data-action="mode-mdi"]'); await waitForState((nextState) => nextState.machine.mode === "mdi", 10000, "retry MDI mode").catch(() => null); state = await getState(); } await setInputValue('[data-action="mdi-command"]', "M428"); await waitForState((nextState) => nextState.machine.mode === "mdi", 10000, "MDI active before M428"); await clickAndWait('[data-action="mdi-submit"]', 800); state = await getState(); await recordResult( "mdi-m428", "MDI 执行 M428", "执行 M428 后 RTCP 打开,kinsType 切到 tcp-*", `rtcp=${state.rtcpState}, kinsType=${state.kinsType}, message=${state.operatorMessage}`, state.rtcpState === "on" && String(state.kinsType).startsWith("tcp-") ? "PASS" : "FAIL", ); await clickAndWait('[data-action="mdi-history"][data-command="M429"]', 800); state = await getState(); await recordResult( "mdi-m429", "MDI 快捷执行 M429", "执行 M429 后 RTCP 关闭,kinsType=identity", `rtcp=${state.rtcpState}, kinsType=${state.kinsType}, message=${state.operatorMessage}`, state.rtcpState === "off" && state.kinsType === "identity" ? "PASS" : "FAIL", ); await clickAndWait('[data-action="kins-tcp"]'); state = await getState(); await recordResult( "sidebar-tcp", "侧栏 TCP 按钮", "点击 TCP 后 RTCP 打开", `rtcp=${state.rtcpState}, kinsType=${state.kinsType}`, state.rtcpState === "on" && String(state.kinsType).startsWith("tcp-") ? "PASS" : "FAIL", ); await clickAndWait('[data-action="kins-identity"]'); state = await getState(); await recordResult( "sidebar-identity", "侧栏 IDENTITY 按钮", "点击 IDENTITY 后 RTCP 关闭", `rtcp=${state.rtcpState}, kinsType=${state.kinsType}`, state.rtcpState === "off" && state.kinsType === "identity" ? "PASS" : "FAIL", ); const beforeRapid = state.feed.rapidOverride; await clickAndWait('[data-action="rapid-override-up"]'); state = await getState(); await recordResult( "rapid-override", "Rapid Override 调整", "点击 + 后 rapidOverride 增加", `before=${beforeRapid}, after=${state.feed.rapidOverride}`, state.feed.rapidOverride > beforeRapid ? "PASS" : "FAIL", ); const beforeFeed = state.feed.feedOverride; await clickAndWait('[data-action="feed-override-down"]'); state = await getState(); await recordResult( "feed-override", "Feed Override 调整", "点击 - 后 feedOverride 减少", `before=${beforeFeed}, after=${state.feed.feedOverride}`, state.feed.feedOverride < beforeFeed ? "PASS" : "FAIL", ); const beforeSpindle = state.spindle.override; await clickAndWait('[data-action="spindle-override-up"]'); state = await getState(); await recordResult( "spindle-override", "Spindle Override 调整", "点击 + 后 spindle.override 增加", `before=${beforeSpindle}, after=${state.spindle.override}`, state.spindle.override > beforeSpindle ? "PASS" : "FAIL", ); const floodBefore = state.coolant.flood; await clickAndWait('[data-action="toggle-flood"]'); state = await getState(); await recordResult( "coolant-flood", "Flood 冷却开关", "点击 Flood 后 flood 状态切换", `before=${floodBefore}, after=${state.coolant.flood}`, state.coolant.flood !== floodBefore ? "PASS" : "FAIL", ); const mistBefore = state.coolant.mist; await clickAndWait('[data-action="toggle-mist"]'); state = await getState(); await recordResult( "coolant-mist", "Mist 冷却开关", "点击 Mist 后 mist 状态切换", `before=${mistBefore}, after=${state.coolant.mist}`, state.coolant.mist !== mistBefore ? "PASS" : "FAIL", ); await clickAndWait('[data-action="view-x"]'); state = await getState(); const viewXPass = state.preview.selectedView === "x"; await recordResult( "view-x", "预览视角 X", "点击 X 后 selectedView=x", `selectedView=${state.preview.selectedView}`, viewXPass ? "PASS" : "FAIL", ); await clickAndWait('[data-action="view-y"]'); state = await getState(); await recordResult( "view-y", "预览视角 Y", "点击 Y 后 selectedView=y", `selectedView=${state.preview.selectedView}`, state.preview.selectedView === "y" ? "PASS" : "FAIL", ); await clickAndWait('[data-action="clear-preview"]'); state = await getState(); await recordResult( "clear-preview", "Clear Preview", "点击 Clear 后 pathPoints=0", `pathPoints=${state.preview.pathPoints}`, state.preview.pathPoints === 0 ? "PASS" : "FAIL", ); await clickAndWait('[data-action="reset-view"]'); state = await getState(); await recordResult( "reset-view", "Fit/Reset View", "点击 Fit 后 selectedView=iso", `selectedView=${state.preview.selectedView}`, state.preview.selectedView === "iso" ? "PASS" : "FAIL", ); await clickAndWait('[data-action="FULL"]'); state = await getState(); const fullOn = state.preview.fullscreen === true; await clickAndWait('[data-action="FULL"]'); const stateAfterFullOff = await getState(); await recordResult( "fullscreen-toggle", "Full 全屏切换", "连续点击两次 Full 后 fullscreen true 再 false", `first=${state.preview.fullscreen}, second=${stateAfterFullOff.preview.fullscreen}`, fullOn && stateAfterFullOff.preview.fullscreen === false ? "PASS" : "FAIL", ); await selectValue('[data-action="select-profile"]', "xyzbc-trt"); await sleep(2000); state = await getState(); await recordResult( "profile-switch", "Profile 切换到 xyzbc-trt", "切换后 machineProfile=xyzbc-trt,INI 重新加载", `machineProfile=${state.machineProfile}, iniLoaded=${state.iniConfigReadiness.loaded}, iniPath=${state.iniConfigReadiness.path}`, state.machineProfile === "xyzbc-trt" && state.iniConfigReadiness.loaded ? "PASS" : "FAIL", ); await capture("02-profile-xyzbc"); await selectValue('[data-action="select-profile"]', "xyzac-trt"); await sleep(2000); state = await getState(); await recordResult( "profile-switch-back", "Profile 切回 xyzac-trt", "切回后 machineProfile=xyzac-trt", `machineProfile=${state.machineProfile}, iniLoaded=${state.iniConfigReadiness.loaded}`, state.machineProfile === "xyzac-trt" && state.iniConfigReadiness.loaded ? "PASS" : "FAIL", ); await clickAndWait('[data-action="stage-linuxcnc-sources"]', 5000); let summary = await getSummary(); const stagedCount = summary.state.machineFileStaging?.gcodeSources?.length || 0; const stagingStatus = classifyWarn( stagedCount > 0 && summary.state.machineFileStaging.status === "staged", `已 staged ${stagedCount} 个 LinuxCNC 五轴 G-code 源文件`, `staging 未完成:status=${summary.state.machineFileStaging.status}, lastError=${summary.state.machineFileStaging.lastError || "-"}`, ); await recordResult( "stage-linuxcnc-sources", "Stage LinuxCNC 5-axis 源程序", "点击 Stage 后应完成 machine files staging 并出现可选 G-code", stagingStatus.text, stagingStatus.status, ); if (stagedCount > 0) { const sourceRel = summary.state.machineFileStaging.gcodeSources[0].sourceRel; await selectValue('[data-action="select-linuxcnc-gcode-source"]', sourceRel); await sleep(4000); summary = await getSummary(); const loadedVendored = summary.state.programSource === "linuxcnc-vendored-5axis-gcode"; await recordResult( "load-vendored-program", "加载 LinuxCNC 五轴源程序", "选择源程序后 programSource=linuxcnc-vendored-5axis-gcode", `programSource=${summary.state.programSource}, activeProgram=${summary.state.activeProgram}, sourceRel=${summary.state.programSourceRel || "-"}`, loadedVendored ? "PASS" : "FAIL", ); await capture("03-vendored-program-loaded"); } else { await recordResult( "load-vendored-program", "加载 LinuxCNC 五轴源程序", "应可加载 staged 后的 vendored 程序", "因 staging 未完成,本项无法继续", "FAIL", ); } state = await getState(); if (!state.machine.powerOn) { await clickAndWait('[data-action="power"]'); await waitForState((nextState) => nextState.machine.powerOn === true, 8000, "machine power on before run").catch(() => null); } if (!state.machine.allHomed) { await clickAndWait('[data-action="mode-jog"]'); await clickAndWait('[data-action="HOME"]'); await waitForState((nextState) => nextState.machine.allHomed === true, 8000, "machine homed before run").catch(() => null); } await clickAndWait('[data-action="mode-auto"]'); await clickAndWait('[data-action="RUN"]', 2500); state = await getState(); await recordResult( "run-program", "Run 程序", "点击 Run 后程序进入 running/complete,活动行或运行反馈推进", `runState=${state.runState}, activeLine=${state.activeLine}, source=${state.programExecutionSourceMode}, feedback=${state.programRuntimeFeedback?.apiName || "-"}`, ["running", "complete"].includes(state.runState) || Number(state.activeLine) !== 501 ? "PASS" : "FAIL", ); await capture("04-run-state"); await clickAndWait('[data-action="PAUSE"]', 1200); state = await getState(); await recordResult( "pause-program", "Pause 程序", "点击 Pause 后 runState=paused", `runState=${state.runState}, interpState=${state.machine.interpState}`, state.runState === "paused" ? "PASS" : "FAIL", ); await clickAndWait('[data-action="RESUME"]', 1200); state = await getState(); await recordResult( "resume-program", "Resume 程序", "点击 Resume 后 runState 返回 running/idle", `runState=${state.runState}, interpState=${state.machine.interpState}`, ["running", "idle", "complete"].includes(state.runState) ? "PASS" : "FAIL", ); const beforeStepLine = state.activeLine; await clickAndWait('[data-action="STEP"]', 1500); state = await getState(); await recordResult( "step-program", "Step 单步执行", "点击 Step 后 Task/HAL 记录 singleStepping=true,并保持暂停态等待后续操作", `runState=${state.runState}, activeLine=${state.activeLine}, taskPaused=${state.machine.taskPaused}, singleStepping=${state.taskHalStatus?.task?.singleStepping}`, state.machine.taskPaused === true && state.taskHalStatus?.task?.singleStepping === true && Number(state.activeLine) >= Number(beforeStepLine) ? "PASS" : "FAIL", ); await clickAndWait('[data-action="STOP"]', 1200); state = await getState(); await recordResult( "stop-program", "Stop 停止程序", "点击 Stop 后 runState=stopped", `runState=${state.runState}, interpState=${state.machine.interpState}`, state.runState === "stopped" ? "PASS" : "FAIL", ); await clickAndWait('[data-action="RELOAD"]', 1200); state = await getState(); await recordResult( "reload-program", "Reload 程序", "点击 Reload 后 runState=idle,程序回到起始状态", `runState=${state.runState}, activeLine=${state.activeLine}`, state.runState === "idle" ? "PASS" : "FAIL", ); const fileInput = await page.$('[data-action="OPEN_FILE"]'); await fileInput.uploadFile(localProgramPath); await sleep(3000); state = await getState(); await recordResult( "open-local-program", "Open 本地 G-code 文件", "上传本地文件后 activeProgram 为上传文件,programSource=upload", `activeProgram=${state.activeProgram}, programSource=${state.programSource}, lineCount=${state.lineCount}`, state.activeProgram.endsWith("test-program.ngc") && ["upload", "operator-file"].includes(state.programSource) ? "PASS" : "FAIL", ); await capture("05-local-program-opened"); await clickAndWait('[data-action="SAVE_SESSION"]', 2500); state = await getState(); const saveSessionStatus = classifyWarn( state.sessionPersistence.status === "saved", `会话已保存:${state.sessionPersistence.path} (${state.sessionPersistence.storageMode})`, `会话保存未成功:status=${state.sessionPersistence.status}, error=${state.sessionPersistence.lastError || "-"}`, ); await recordResult( "save-session", "Save Session", "点击 Save Session 后会话状态应为 saved", saveSessionStatus.text, saveSessionStatus.status, ); await clickAndWait('[data-action="mode-manual"]'); await waitForState((nextState) => nextState.machine.mode === "manual", 8000, "manual mode before session mutation").catch(() => null); await clickAndWait('[data-action="JOG_X_POS"]'); await waitForState((nextState) => Number(nextState.axisPose.x) !== Number(state.axisPose.x), 8000, "axis changed before restore").catch(() => null); const modifiedState = await getState(); await clickAndWait('[data-action="RESTORE_SESSION"]', 2500); state = await getState(); await recordResult( "restore-session", "Restore Session", "点击 Restore Session 后会话恢复到最近保存快照", `modifiedX=${modifiedState.axisPose.x}, restoredX=${state.axisPose.x}, status=${state.sessionPersistence.status}`, state.sessionPersistence.status === "restored" && state.axisPose.x !== modifiedState.axisPose.x ? "PASS" : "FAIL", ); await clickAndWait('[data-action="AUDIT_FULL_BOUNDARY"]', 6000); summary = await getSummary(); const auditStatus = classifyWarn( !summary.state.interpreterExecutionPending, `Audit 执行完成,fullBoundary=${summary.state.fullExecutionBoundary?.fullLinuxCncProgramExecutionReady}`, "Audit 触发后仍在 pending 或未返回结果", ); await recordResult( "audit-full-boundary", "Audit Full Boundary", "点击 Audit 后应触发五轴 machine-file 运行审计并刷新边界状态", `${auditStatus.text}; boundaryStatus=${summary.state.fullExecutionBoundary?.boundaryStatus || "-"}; machineRun=${summary.state.machineFileExecution?.summary?.machineFileExecutionReady ?? "-"}`, auditStatus.status, ); await capture("06-after-audit"); await clickAndWait('[data-action="estop"]'); state = await getState(); await recordResult( "estop", "E-STOP 急停", "点击 E-STOP 后 estopActive=true,runState=estopped", `estopActive=${state.machine.estopActive}, runState=${state.runState}, powerOn=${state.machine.powerOn}`, state.machine.estopActive && state.runState === "estopped" ? "PASS" : "FAIL", ); await clickAndWait('[data-action="reset"]'); state = await getState(); await recordResult( "reset", "RESET 复位", "点击 RESET 后 estopActive=false,powerOn=false,taskState=estop-reset", `estopActive=${state.machine.estopActive}, powerOn=${state.machine.powerOn}, taskState=${state.machine.taskState}`, !state.machine.estopActive && !state.machine.powerOn && state.machine.taskState === "estop-reset" ? "PASS" : "FAIL", ); const finalSummary = await getSummary(); await capture("07-final"); const screenshotAnalysis = await analyzeScreenshot(screenshots["01-home"]); const report = { generatedAt: new Date().toISOString(), targetUrl, chromePath: CHROME_PATH, screenshots, screenshotAnalysis, finalSummary, findings, consoleLogs, pageErrors, requestFailures, }; report.status = findings.some((finding) => finding.status === "FAIL") || pageErrors.length > 0 ? "FAIL" : "PASS"; await fs.writeFile(path.join(OUTPUT_DIR, "site-test-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8"); console.log(`site_test_status=${report.status}`); console.log(`site_test_target_url=${targetUrl}`); console.log(`site_test_report=/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/output/site-test-report.json`); if (report.status !== "PASS") { process.exitCode = 1; } } finally { await browser.close().catch(() => {}); if (server) await new Promise((resolve) => server.close(resolve)); } async function analyzeScreenshot(filePath) { if (!filePath) return null; const buffer = await fs.readFile(filePath); const png = PNG.sync.read(buffer); const { width, height, data } = png; let sum = 0; let nonBlackPixels = 0; for (let i = 0; i < data.length; i += 4) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; const luminance = (r + g + b) / 3; sum += luminance; if (luminance > 8) nonBlackPixels += 1; } return { width, height, averageLuminance: Number((sum / (width * height)).toFixed(2)), nonBlackRatio: Number((nonBlackPixels / (width * height)).toFixed(4)), }; }