import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import { randomUUID } from "node:crypto"; import puppeteer from "puppeteer-core"; import { PNG } from "pngjs"; const REPO_ROOT = path.resolve("/home/meswork/cnc_wams"); const QA_ROOT = path.join(REPO_ROOT, "qa/web-rtcp-5axis-site-test"); const OUTPUT_DIR = path.join(QA_ROOT, "output"); const CHROME_PATH = process.env.CHROME_PATH || process.env.CHROMIUM || "/usr/bin/google-chrome"; const TARGET_URL = process.env.TARGET_URL || ""; const APP_URL = process.env.APP_URL || "/web-rtcp-5axis-sim-plan/app/index.html"; const EVIDENCE_SCOPE = process.env.EVIDENCE_SCOPE || (TARGET_URL ? "cloud-button-control-evidence" : "button-control-evidence"); const SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", EVIDENCE_SCOPE); const REPORT_BASENAME = `${EVIDENCE_SCOPE}-report`; const JOB_ID = process.env.JOB_ID || `btn-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`; const REPORT_ID = process.env.REPORT_ID || `report-${JOB_ID}`; await fs.mkdir(OUTPUT_DIR, { recursive: true }); await fs.mkdir(SCREENSHOT_DIR, { recursive: true }); 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 static server"); targetUrl = `http://127.0.0.1:${address.port}${APP_URL}`; } const browser = await puppeteer.launch({ headless: true, executablePath: CHROME_PATH, defaultViewport: { width: 1500, height: 1050, deviceScaleFactor: 1 }, ignoreHTTPSErrors: true, args: [ "--ignore-certificate-errors", "--disable-gpu", "--enable-webgl", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--no-sandbox", ], }); const page = await browser.newPage(); const consoleErrors = []; const pageErrors = []; page.on("console", (msg) => { if (msg.type() === "error") consoleErrors.push(msg.text()); }); page.on("pageerror", (error) => pageErrors.push(error.message)); const report = { jobId: JOB_ID, reportId: REPORT_ID, evidenceScope: EVIDENCE_SCOPE, generatedAt: new Date().toISOString(), targetUrl, chromePath: CHROME_PATH, screenshotsDir: SCREENSHOT_DIR, steps: [], checks: [], consoleErrors, pageErrors, }; try { await page.goto(report.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 canvas = document.querySelector("[data-five-axis-canvas]"); return canvas?.dataset?.threeReady === "true"; }, { timeout: 20000 }); await windowReady(); await captureStep("01-initial", "Initial UI", "Browser app loaded before machine preparation."); await loadOperatorProgram(); await captureStep("02-program-loaded", "Program loaded", "Short operator G-code loaded through LinuxCNC interpreter WASM."); await click("power"); await waitForState((state) => state.machine.powerOn === true, 10000, "power on"); await click("mode-manual"); await waitForState((state) => state.machine.mode === "manual", 10000, "manual mode"); await captureStep("03-before-home", "Before HOME", "Machine powered on in manual mode before HOME."); await click("HOME"); await waitForState((state) => state.machine.allHomed === true && state.machine.mode === "manual", 10000, "HOME complete"); await captureStep("04-after-home", "After HOME", "HOME command preserves allHomed state in Web/task gate."); await click("mode-auto"); await waitForState((state) => state.machine.mode === "auto", 10000, "auto mode"); await captureStep("05-ready-for-run", "Ready for RUN", "POWER, HOME, AUTO, and loaded G-code are ready before RUN."); await click("RUN"); await waitForState((state) => ( state.runState === "running" && state.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm" ), 15000, "RUN active"); await wait(250); await captureStep("06-after-run", "After RUN", "RUN starts task/HAL backed program execution."); await captureStep("07-before-pause", "Before PAUSE", "Program is running before PAUSE."); await click("PAUSE"); await waitForState((state) => state.runState === "paused" && state.machine.taskPaused === true, 10000, "PAUSE active"); await captureStep("08-after-pause", "After PAUSE", "PAUSE sets runState paused and taskPaused true."); await captureStep("09-before-resume", "Before RESUME", "Program is paused before RESUME."); await click("RESUME"); await waitForState((state) => state.runState === "running" && state.machine.interpState === "reading", 10000, "RESUME active"); await wait(150); await captureStep("10-after-resume", "After RESUME", "RESUME returns task/HAL execution to running/reading."); await click("PAUSE"); await waitForState((state) => state.runState === "paused" && state.machine.taskPaused === true, 10000, "PAUSE before STEP"); await captureStep("11-before-step", "Before STEP", "Program is paused before STEP."); await click("STEP"); await waitForState((state) => ( state.machine.interpState === "paused" && state.machine.taskPaused === true && state.taskHalStatus?.task?.singleStepping === true ), 10000, "STEP active"); await captureStep("12-after-step", "After STEP", "STEP sends EMC_TASK_PLAN_STEP and leaves task paused with singleStepping true."); await captureStep("13-before-stop", "Before STOP", "Paused single-step state before STOP."); await click("STOP"); await waitForState((state) => ( state.runState === "stopped" && state.machine.interpState === "idle" && state.taskHalStatus?.motionStatus?.motion?.aborted === true ), 10000, "STOP active"); await captureStep("14-after-stop", "After STOP", "STOP aborts task/HAL motion and returns interpreter state to idle."); addChecks(); report.status = report.checks.every((check) => check.pass) && pageErrors.length === 0 ? "PASS" : "FAIL"; const jsonPath = path.join(OUTPUT_DIR, `${REPORT_BASENAME}.json`); report.jsonPath = jsonPath; const pdfPath = path.join(OUTPUT_DIR, `${REPORT_BASENAME}.pdf`); report.pdfPath = pdfPath; await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); await writePdfReport(pdfPath, report); console.log(`button_control_evidence_status=${report.status}`); console.log(`button_control_evidence_job_id=${report.jobId}`); console.log(`button_control_evidence_report_id=${report.reportId}`); console.log(`button_control_evidence_json=${jsonPath}`); console.log(`button_control_evidence_pdf=${pdfPath}`); console.log(`button_control_evidence_screenshots=${SCREENSHOT_DIR}`); if (report.status !== "PASS") process.exitCode = 1; } finally { await page.close().catch(() => {}); await browser.close().catch(() => {}); if (server) await new Promise((resolve) => server.close(resolve)); } async function windowReady() { await waitForState((state) => ( state.kinematicsRuntimeReadiness?.loaded === true && state.interpreterRuntimeReadiness?.loaded === true && state.taskHalRuntimeReadiness?.loaded === true ), 30000, "runtime readiness"); await waitForState((state) => ( !state.interpreterExecutionPending && state.machineFileStaging?.status === "staged" ), 30000, "machine file seed ready") .catch(() => null); } async function loadOperatorProgram() { await page.evaluate(() => { window.webRtcp5AxisSimulation.dispatch({ type: "LOAD_PROGRAM", filename: "button-control-evidence.ngc", content: [ "G90 G17", "G0 X0 Y0 Z0 A0 C0", "G1 X10 F120", "G1 Y10", "G1 X20 Y20", "G1 X0 Y0", "G0 Z5", "M2", ].join("\n"), }); }); await waitForState((state) => ( state.activeProgram === "button-control-evidence.ngc" && state.programExecutionSourceMode === "linuxcnc-interpreter-wasm" && state.programExecution?.summary?.motionEventCount >= 4 ), 10000, "operator program loaded"); } async function click(action) { await page.evaluate((selector) => { const button = document.querySelector(selector); if (!button) throw new Error(`missing button ${selector}`); button.click(); }, `[data-action="${action}"]`); } async function captureStep(name, title, description) { const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); const [state, buttons, canvasDataset, pixelStats] = await Promise.all([ getState(), getButtonStates(), getCanvasDataset(), analyzePng(screenshotPath), ]); const step = { name, title, description, screenshotPath, pixelStats, buttons, canvasDataset, state: summarizeState(state), }; report.steps.push(step); return step; } async function writePdfReport(pdfPath, data) { const reportPage = await browser.newPage(); const rows = data.checks.map((item) => ` ${escapeHtml(item.name)} ${item.pass ? "PASS" : "FAIL"} ${escapeHtml(item.detail)} `).join(""); const steps = data.steps.map((step) => `

${escapeHtml(step.name)} - ${escapeHtml(step.title)}

${escapeHtml(step.description)}

Screenshot: ${escapeHtml(step.screenshotPath)}

State: ${escapeHtml(JSON.stringify({ runState: step.state.runState, machine: step.state.machine, task: step.state.taskHalStatus?.task, motion: step.state.taskHalStatus?.motionStatus?.motion, }))}

`).join(""); await reportPage.setContent(`

Web RTCP 5 Axis Button Control Evidence

Status: ${escapeHtml(data.status)}
Job ID: ${escapeHtml(data.jobId)}
Report ID: ${escapeHtml(data.reportId)}
Target: ${escapeHtml(data.targetUrl)}
Generated: ${escapeHtml(data.generatedAt)}
Screenshots: ${escapeHtml(data.screenshotsDir)}

Checks

${rows}
CheckStatusEvidence

Steps

${steps} `, { waitUntil: "load" }); await reportPage.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "12mm", right: "10mm", bottom: "12mm", left: "10mm" }, }); await reportPage.close(); } async function getState() { return page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()))); } async function getButtonStates() { return page.evaluate(() => { const actions = ["RUN", "STOP", "PAUSE", "RESUME", "STEP", "HOME"]; return Object.fromEntries(actions.map((action) => { const button = document.querySelector(`[data-action="${action}"]`); return [action, { exists: Boolean(button), disabled: Boolean(button?.disabled), commandReady: button?.dataset?.commandReady || null, ariaDisabled: button?.getAttribute("aria-disabled"), title: button?.getAttribute("title") || "", }]; })); }); } async function getCanvasDataset() { return page.$eval("[data-five-axis-canvas]", (canvas) => ({ ...canvas.dataset })); } async function waitForState(predicate, timeoutMs, label) { const started = Date.now(); let lastState = null; while (Date.now() - started < timeoutMs) { lastState = await getState(); if (predicate(lastState)) return lastState; await wait(50); } throw new Error(`timeout waiting for ${label}: ${JSON.stringify(summarizeState(lastState || {}))}`); } function addChecks() { const byName = Object.fromEntries(report.steps.map((step) => [step.name, step])); const afterHome = byName["04-after-home"]?.state; const afterRun = byName["06-after-run"]?.state; const afterPause = byName["08-after-pause"]?.state; const afterResume = byName["10-after-resume"]?.state; const afterStep = byName["12-after-step"]?.state; const afterStop = byName["14-after-stop"]?.state; report.checks.push( check("HOME keeps all axes homed", afterHome?.machine?.allHomed === true, JSON.stringify(afterHome?.machine)), check("RUN uses task/HAL runtime feedback", afterRun?.runState === "running" && afterRun?.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm", JSON.stringify(afterRun?.programRuntimeFeedback)), check("PAUSE sets paused state", afterPause?.runState === "paused" && afterPause?.machine?.taskPaused === true, JSON.stringify(afterPause?.machine)), check("RESUME returns to reading", afterResume?.runState === "running" && afterResume?.machine?.interpState === "reading", JSON.stringify(afterResume?.machine)), check("STEP records single stepping", afterStep?.machine?.taskPaused === true && afterStep?.taskHalStatus?.task?.singleStepping === true, JSON.stringify(afterStep?.taskHalStatus?.task)), check("STOP aborts motion", afterStop?.runState === "stopped" && afterStop?.taskHalStatus?.motionStatus?.motion?.aborted === true, JSON.stringify(afterStop?.taskHalStatus?.motionStatus?.motion)), check("Screenshots are nonblank", report.steps.every((step) => step.pixelStats.nonBlackRatio > 0.1), report.steps.map((step) => `${step.name}:${step.pixelStats.nonBlackRatio}`).join(", ")), check("Control buttons expose readiness attributes", report.steps.every((step) => Object.values(step.buttons).every((button) => button.exists && button.commandReady !== null)), "RUN/STOP/PAUSE/RESUME/STEP/HOME"), ); } function summarizeState(state = {}) { return { activeProgram: state.activeProgram, runState: state.runState, activeLine: state.activeLine, machine: { powerOn: state.machine?.powerOn, taskState: state.machine?.taskState, mode: state.machine?.mode, allHomed: state.machine?.allHomed, interpState: state.machine?.interpState, taskPaused: state.machine?.taskPaused, }, axisPose: pickAxes(state.axisPose), dro: pickAxes(state.dro), programRuntimeFeedback: state.programRuntimeFeedback ? { sourceMode: state.programRuntimeFeedback.sourceMode, line: state.programRuntimeFeedback.line, taskCycle: state.programRuntimeFeedback.taskCycle, currentVelocityMmPerMin: state.programRuntimeFeedback.currentVelocityMmPerMin, axisPose: pickAxes(state.programRuntimeFeedback.axisPose), } : null, taskHalStatus: state.taskHalStatus ? { task: { mode: state.taskHalStatus.task?.mode, interpState: state.taskHalStatus.task?.interpState, taskPaused: state.taskHalStatus.task?.taskPaused, singleStepping: state.taskHalStatus.task?.singleStepping, }, ui: { activeLine: state.taskHalStatus.ui?.activeLine, taskCycle: state.taskHalStatus.ui?.taskCycle, servoCycle: state.taskHalStatus.ui?.servoCycle, }, motionStatus: { motion: { enabled: state.taskHalStatus.motionStatus?.motion?.enabled, paused: state.taskHalStatus.motionStatus?.motion?.paused, aborted: state.taskHalStatus.motionStatus?.motion?.aborted, queueDepth: state.taskHalStatus.motionStatus?.motion?.queueDepth, }, }, } : null, taskHalStatusLoop: state.taskHalStatusLoop, operatorMessage: state.operatorMessage, }; } 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 filePath = targetPath; let stat = await fs.stat(filePath).catch(() => null); if (stat?.isDirectory()) { filePath = path.join(filePath, "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)); } }); } function contentTypeFor(filePath) { const ext = path.extname(filePath).toLowerCase(); return { ".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", }[ext] || "application/octet-stream"; } async function analyzePng(filePath) { const png = PNG.sync.read(await fs.readFile(filePath)); let luminanceSum = 0; let nonBlack = 0; for (let index = 0; index < png.data.length; index += 4) { const luminance = png.data[index] * 0.2126 + png.data[index + 1] * 0.7152 + png.data[index + 2] * 0.0722; luminanceSum += luminance; if (luminance > 8) nonBlack += 1; } const total = png.width * png.height; return { width: png.width, height: png.height, averageLuminance: Number((luminanceSum / total).toFixed(2)), nonBlackRatio: Number((nonBlack / total).toFixed(4)), }; } function pickAxes(value = {}) { return { x: Number(value?.x || 0), y: Number(value?.y || 0), z: Number(value?.z || 0), a: Number(value?.a || 0), b: Number(value?.b || 0), c: Number(value?.c || 0), }; } function check(name, pass, detail) { return { name, pass: Boolean(pass), detail: String(detail ?? "-") }; } function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }