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 QA_ROOT = path.resolve("/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test"); const OUTPUT_DIR = path.join(QA_ROOT, "output"); const SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "toolpath-preview-cases"); const CHROME_PATH = process.env.CHROME_PATH || "/usr/bin/google-chrome"; const FIXTURE_URL = "/web-rtcp-5axis-sim-plan/app/index.html"; await fs.mkdir(OUTPUT_DIR, { recursive: true }); await fs.mkdir(SCREENSHOT_DIR, { recursive: true }); 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", ".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)); } }); } const 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 static server"); } const baseUrl = `http://127.0.0.1:${address.port}`; const browser = await puppeteer.launch({ headless: true, executablePath: CHROME_PATH, defaultViewport: { width: 1600, height: 1200, deviceScaleFactor: 1 }, args: [ "--disable-gpu", "--enable-webgl", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--no-sandbox", ], }); const page = await browser.newPage(); const consoleErrors = []; page.on("console", (msg) => { if (msg.type() === "error") { consoleErrors.push(msg.text()); } }); page.on("pageerror", (error) => { consoleErrors.push(error.message); }); const report = { generatedAt: new Date().toISOString(), targetUrl: `${baseUrl}${FIXTURE_URL}`, projectPath: path.join(REPO_ROOT, "web-rtcp-5axis-sim-plan", "app", "index.html"), chromePath: CHROME_PATH, screenshots: {}, cases: [], consoleErrors, }; async function wait(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForState(predicate, timeoutMs = 20000, label = "state condition") { const start = Date.now(); while (Date.now() - start < timeoutMs) { const snapshot = await page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()))); if (predicate(snapshot)) return snapshot; await wait(100); } throw new Error(`timeout waiting for ${label}`); } async function getCanvasDataset() { return page.$eval("[data-five-axis-canvas]", (canvas) => ({ ...canvas.dataset })); } async function waitForCanvasReady(timeoutMs = 20000) { await page.waitForFunction(() => { const canvas = document.querySelector("[data-five-axis-canvas]"); return canvas?.dataset?.threeReady === "true" && canvas?.dataset?.threePreviewScope === "machine-reference-and-toolpath"; }, { timeout: timeoutMs }); } async function captureCase(name) { const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`); const canvas = await page.$("[data-five-axis-canvas]"); if (!canvas) { throw new Error(`missing canvas for ${name}`); } await canvas.screenshot({ path: screenshotPath }); report.screenshots[name] = screenshotPath; return screenshotPath; } async function recordCase(name, summary, checks = []) { const dataset = await getCanvasDataset(); const state = await page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()))); const screenshotPath = report.screenshots[name]; const pixelStats = screenshotPath ? await analyzePng(screenshotPath) : null; report.cases.push({ name, summary, status: checks.every((check) => check.pass) ? "PASS" : "FAIL", checks, pixelStats, dataset, state: { activeProgram: state.activeProgram, activeLine: state.activeLine, programSource: state.programSource, programExecutionSourceMode: state.programExecutionSourceMode, programExecutionSummary: state.programExecution?.summary || null, runState: state.runState, rtcpState: state.rtcpState, kinsType: state.kinsType, activeLine: state.activeLine, programExecutionMotionIndex: state.programExecutionMotionIndex, programExecutionSampleIndex: state.programExecutionSampleIndex, programRuntimeFeedbackSource: state.programRuntimeFeedback?.sourceMode || null, }, }); } function previewChecks(dataset, { requirePathPoints = true, requireExecutedPath = false, requireArcPoints = false, requireRapidFeed = false, expectPathPoints = null, expectRtcp = null, } = {}) { const checks = [ check("canvas ready", dataset.threeReady === "true", `threeReady=${dataset.threeReady}`), check("WebGL renderer", dataset.threeRenderer === "webgl", `renderer=${dataset.threeRenderer}`), check("机床参考模型", dataset.threePreviewScope === "machine-reference-and-toolpath" && dataset.threeMachineReferenceModel === "webgl-five-axis-reference", `scope=${dataset.threePreviewScope}, model=${dataset.threeMachineReferenceModel}`), check("TCP 球标记", dataset.threeTcpMarker === "sphere" && dataset.threeToolExecutionMarker === "true", `tcp=${dataset.threeTcpMarker}, marker=${dataset.threeToolExecutionMarker}`), check("刀轴线", dataset.threeToolAxisMarker === "line", `toolAxisMarker=${dataset.threeToolAxisMarker}`), check("场景对象数量", Number(dataset.threeSceneObjects || 0) >= 12, `sceneObjects=${dataset.threeSceneObjects}`), check("无 G-code 语义生成", dataset.threeNoGcodeSemanticsGeneration === "ok", `semanticGuard=${dataset.threeNoGcodeSemanticsGeneration}`), ]; if (requirePathPoints) { checks.push(check("刀路预览点", Number(dataset.threePathPoints || 0) >= 1, `pathPoints=${dataset.threePathPoints}`)); } if (requireExecutedPath) { checks.push(check("执行轨迹点", Number(dataset.threeExecutedPathPoints || 0) >= 1, `executed=${dataset.threeExecutedPathPoints}`)); } if (requireArcPoints) { checks.push(check("圆弧轨迹点", Number(dataset.threeArcPathPoints || 0) >= 1, `arc=${dataset.threeArcPathPoints}`)); } if (requireRapidFeed) { checks.push(check("rapid/feed 区分", Number(dataset.threeRapidPathPoints || 0) >= 1 && Number(dataset.threeFeedPathPoints || 0) >= 1, `rapid=${dataset.threeRapidPathPoints}, feed=${dataset.threeFeedPathPoints}`)); } if (expectPathPoints !== null) { checks.push(check("路径点期望", Number(dataset.threePathPoints || 0) === expectPathPoints, `pathPoints=${dataset.threePathPoints}, expected=${expectPathPoints}`)); } if (expectRtcp !== null) { checks.push(check("RTCP 状态", dataset.threeRtcpState === expectRtcp, `rtcp=${dataset.threeRtcpState}`)); } return checks; } function check(name, pass, detail) { return { name, pass: Boolean(pass), detail }; } function assertChecks(caseName, checks) { const failed = checks.filter((item) => !item.pass); if (failed.length > 0) { console.warn(`${caseName} failed: ${failed.map((item) => `${item.name} (${item.detail})`).join("; ")}`); } } try { await page.goto(`${baseUrl}${FIXTURE_URL}`, { waitUntil: "networkidle2", timeout: 60000 }); await page.waitForSelector('[data-shell="gmoccapy-5axis"]', { timeout: 15000 }); await page.waitForFunction(() => Boolean(window.webRtcp5AxisSimulation?.getState), { timeout: 15000 }); await waitForState((state) => state.kinematicsRuntimeReadiness?.loaded === true, 20000, "kinematics runtime ready"); await waitForCanvasReady(); await wait(1200); let dataset = await getCanvasDataset(); let checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true }); assertChecks("01-home-toolpath", checks); await captureCase("01-home-toolpath"); await recordCase("01-home-toolpath", "默认首屏预览:机床参考模型、TCP 球、刀轴线、fixture 路径和执行轨迹可见", checks); await page.evaluate(() => { window.webRtcp5AxisSimulation.dispatch({ type: "LOAD_PROGRAM", filename: "operator-demo.ngc", content: [ "G90 G17", "G0 X0 Y0 Z0", "G1 X10 F100", "G1 Y10", "G1 X0", "G1 Y0", "G0 Z5", "M5", "M2", ].join("\n"), }); }); await waitForState((state) => state.programExecutionSourceMode === "linuxcnc-interpreter-wasm" && state.activeProgram === "operator-demo.ngc", 20000, "operator demo loaded"); await waitForCanvasReady(); await wait(500); dataset = await getCanvasDataset(); checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireRapidFeed: true }); assertChecks("02-operator-demo-toolpath", checks); await captureCase("02-operator-demo-toolpath"); await recordCase("02-operator-demo-toolpath", "本地矩形 G-code:验证 LinuxCNC interpreter canonical motion、rapid/feed 区分、执行轨迹和 TCP 标记", checks); await page.evaluate(() => { window.webRtcp5AxisSimulation.dispatch({ type: "LOAD_PROGRAM", filename: "operator-arc-demo.ngc", content: [ "G90 G17", "G0 X1 Y0 Z0", "G2 X0 Y1 I-1 J0 F60", "M2", ].join("\n"), }); }); await waitForState((state) => state.activeProgram === "operator-arc-demo.ngc" && state.programExecution?.summary?.motionTypes?.includes("ARC_FEED"), 20000, "arc demo loaded"); await waitForCanvasReady(); await wait(500); dataset = await getCanvasDataset(); checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireArcPoints: true }); assertChecks("03-arc-demo-toolpath", checks); await captureCase("03-arc-demo-toolpath"); await recordCase("03-arc-demo-toolpath", "圆弧 G-code:验证 ARC_FEED 进入弧线轨迹图层并保留执行轨迹", checks); await page.evaluate(() => document.querySelector('[data-action="clear-preview"]').click()); await waitForCanvasReady(); await wait(500); dataset = await getCanvasDataset(); checks = previewChecks(dataset, { requirePathPoints: false, expectPathPoints: 0 }); assertChecks("04-clear-preview-reference", checks); await captureCase("04-clear-preview-reference"); await recordCase("04-clear-preview-reference", "清空刀路后:路径点为 0,但机床参考模型、TCP 球和刀轴线仍应可见", checks); await waitForState((state) => state.machineFileStaging?.status === "staged" && (state.machineFileStaging?.gcodeSources?.length || 0) >= 4, 25000, "machine files staged"); await page.select('[data-action="select-linuxcnc-gcode-source"]', "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc"); await waitForState((state) => state.activeProgram?.endsWith("impeller-7bl-xyzac.ngc"), 25000, "vendored impeller loaded"); await waitForCanvasReady(); await wait(1200); dataset = await getCanvasDataset(); checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireRapidFeed: true, expectRtcp: "on" }); assertChecks("05-vendored-impeller-toolpath", checks); await captureCase("05-vendored-impeller-toolpath"); await recordCase("05-vendored-impeller-toolpath", "LinuxCNC vendored 五轴 impeller 程序:验证长路径、switchkins/RTCP 状态、rapid/feed 图层和 TCP 执行轨迹", checks); await page.evaluate(() => document.querySelector('[data-action="power"]').click()); await waitForState((state) => state.machine.taskState === "on", 10000, "machine powered on"); await page.evaluate(() => document.querySelector('[data-action="mode-manual"]').click()); await waitForState((state) => state.machine.mode === "manual", 10000, "manual mode selected"); await page.evaluate(() => document.querySelector('[data-action="HOME"]').click()); await waitForState((state) => state.machine.allHomed === true, 10000, "machine homed"); await page.evaluate(() => document.querySelector('[data-action="mode-auto"]').click()); await waitForState((state) => state.machine.mode === "auto", 10000, "auto mode selected"); await page.evaluate(() => document.querySelector('[data-action="kins-tcp"]').click()); await waitForState((state) => state.rtcpState === "on" && state.kinsType === "tcp-xyzac", 10000, "RTCP enabled"); await page.evaluate(() => document.querySelector('[data-action="RUN"]').click()); await waitForState((state) => state.runState === "running" && state.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm", 15000, "program running"); await waitForCanvasReady(); await wait(600); dataset = await getCanvasDataset(); checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireRapidFeed: true, expectRtcp: "on" }); assertChecks("06-running-rtcp-toolpath", checks); await captureCase("06-running-rtcp-toolpath"); await recordCase("06-running-rtcp-toolpath", "G-code 运行态:验证 task/HAL runtime feedback 驱动执行轨迹、当前段高亮、TCP 球和刀轴线跟随", checks); report.consoleErrors = consoleErrors; await fs.writeFile( path.join(OUTPUT_DIR, "toolpath-preview-cases.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8", ); } finally { await page.close().catch(() => {}); await browser.close().catch(() => {}); await new Promise((resolve) => server.close(resolve)); } async function analyzePng(filePath) { const buffer = await fs.readFile(filePath); const png = PNG.sync.read(buffer); const { width, height, data } = png; let luminanceSum = 0; let nonBlack = 0; for (let index = 0; index < data.length; index += 4) { const luminance = data[index] * 0.2126 + data[index + 1] * 0.7152 + data[index + 2] * 0.0722; luminanceSum += luminance; if (luminance > 8) nonBlack += 1; } const total = width * height; return { width, height, averageLuminance: Number((luminanceSum / total).toFixed(2)), nonBlackRatio: Number((nonBlack / total).toFixed(4)), }; }