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 SCREENSHOT_DIR = path.join(OUTPUT_DIR, "gmoccapy-xyzab"); 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 JOB_ID = process.env.JOB_ID || `xyzab-${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: "gmoccapy-xyzab-function", 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(() => document.querySelector("[data-five-axis-canvas]")?.dataset?.threeReady === "true", { timeout: 20000 }); await waitForState((state) => state.kinematicsRuntimeReadiness?.loaded === true, 30000, "initial runtime ready"); await captureStep("01-loaded", "Initial gmoccapy shell", "Default shell loaded with icon registry and diagnostics."); await page.select('[data-action="select-profile"]', "gmoccapy-xyzab"); await waitForState((state) => state.machineProfile === "gmoccapy-xyzab", 15000, "gmoccapy XYZAB selected"); await wait(500); await captureStep("02-profile-selected", "gmoccapy XYZAB selected", "Reference profile shows XYZAB/trivkins and disables TCP promotion."); await click("RUN"); await waitForState((state) => state.operatorMessage === "run blocked: machine must be on", 5000, "RUN blocked before power"); await captureStep("03-run-blocked-power", "RUN blocked before power", "RUN remains clickable but reports LinuxCNC/gmoccapy gate reason."); await click("power"); await waitForState((state) => state.machine.powerOn === true, 8000, "power on"); await click("HOME"); await waitForState((state) => state.machine.allHomed === true, 8000, "home complete"); await click("mode-auto"); await waitForState((state) => state.machine.mode === "auto", 8000, "auto mode"); await click("RUN"); await waitForState((state) => isReferenceRunBlockedReason(state.operatorMessage), 5000, "RUN blocked by reference evidence"); await captureStep("04-run-blocked-reference-evidence", "RUN blocked by reference evidence", "XYZAB passes power/home/auto gates but remains blocked without complete native INI/machine-file/runtime evidence."); await click("mode-manual"); await waitForState((state) => state.machine.mode === "manual", 8000, "manual mode"); await click("spindle-forward"); await waitForState((state) => state.spindle.direction === "forward" && state.spindle.enabled === true, 5000, "spindle forward"); await click("spindle-stop"); await waitForState((state) => state.spindle.direction === "stop" && state.spindle.enabled === false, 5000, "spindle stop"); await click("toggle-flood"); await waitForState((state) => state.coolant.flood === false, 5000, "flood toggled"); await click("toggle-mist"); await waitForState((state) => state.coolant.mist === true, 5000, "mist toggled"); await captureStep("05-controls-active", "Spindle and coolant controls", "Spindle/coolant icons update through task policy guarded store actions."); await captureStep("06-hal-diagnostics", "HAL and communication diagnostics", "Info panel exposes native NML/HAL/postgui references and Web runtime boundary."); addChecks(); report.status = report.checks.every((check) => check.pass) && pageErrors.length === 0 ? "PASS" : "FAIL"; const jsonPath = path.join(OUTPUT_DIR, "gmoccapy-xyzab-function-report.json"); const pdfPath = path.join(OUTPUT_DIR, "gmoccapy-xyzab-function-report.pdf"); report.jsonPath = jsonPath; report.pdfPath = pdfPath; await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); await writePdfReport(pdfPath, report); console.log(`gmoccapy_xyzab_function_status=${report.status}`); console.log(`gmoccapy_xyzab_function_job_id=${report.jobId}`); console.log(`gmoccapy_xyzab_function_report_id=${report.reportId}`); console.log(`gmoccapy_xyzab_function_json=${jsonPath}`); console.log(`gmoccapy_xyzab_function_pdf=${pdfPath}`); console.log(`gmoccapy_xyzab_function_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 captureStep(name, title, description) { const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); const [state, dom, pixelStats] = await Promise.all([ getState(), getDomEvidence(), analyzePng(screenshotPath), ]); const step = { name, title, description, screenshotPath, pixelStats, state: summarizeState(state), dom, }; report.steps.push(step); return step; } async function getDomEvidence() { return page.evaluate(() => { const text = (selector) => document.querySelector(selector)?.textContent?.trim() || ""; const button = (action) => { const element = document.querySelector(`[data-action="${action}"]`); return { exists: Boolean(element), iconName: element?.dataset?.iconName || null, iconVariant: element?.dataset?.iconVariant || null, buttonId: element?.dataset?.gmoccapyButtonId || null, active: element?.dataset?.active || null, commandReady: element?.dataset?.commandReady || null, title: element?.getAttribute("title") || "", }; }; return { buttons: Object.fromEntries([ "estop", "power", "mode-manual", "mode-auto", "mode-mdi", "RUN", "STOP", "PAUSE", "HOME", "spindle-forward", "spindle-stop", "spindle-reverse", "toggle-flood", "toggle-mist", "view-x", ].map((action) => [action, button(action)])), profileSummary: text('[data-linuxcnc-boundary="profile-summary"]'), iniKins: text('[data-linuxcnc-ini="kins"]'), taskGates: text('[data-linuxcnc-task-policy="gates"]'), gmoccapyCommBoundary: text('[data-gmoccapy-comm="boundary"]'), gmoccapyNativeCommand: text('[data-gmoccapy-comm="native-command"]'), gmoccapyWebPath: text('[data-gmoccapy-comm="web-path"]'), gmoccapyPostgui: text('[data-gmoccapy-comm="postgui"]'), gmoccapyHalBoundary: text('[data-gmoccapy-hal="boundary"]'), gmoccapyHalPostgui: text('[data-gmoccapy-hal="postgui"]'), operatorMessage: text("[data-operator-message]"), }; }); } 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(step.state))}

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

gmoccapy XYZAB Function 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 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 getState() { return page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()))); } 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 profile = byName["02-profile-selected"]; const blockedPower = byName["03-run-blocked-power"]; const blockedReference = byName["04-run-blocked-reference-evidence"]; const controls = byName["05-controls-active"]; const diagnostics = byName["06-hal-diagnostics"]; const buttons = diagnostics?.dom?.buttons || {}; report.checks.push( check("XYZAB profile selected", profile?.state?.machineProfile === "gmoccapy-xyzab" && profile?.state?.coordinates === "XYZAB", JSON.stringify(profile?.state)), check("Reference profile blocks RTCP promotion", profile?.state?.rtcpState === "off" && profile?.state?.profileTcpCapable === false, JSON.stringify(profile?.state)), check("RUN blocked before power", blockedPower?.state?.operatorMessage === "run blocked: machine must be on", blockedPower?.state?.operatorMessage), check("RUN blocked by reference evidence", isReferenceRunBlockedReason(blockedReference?.state?.operatorMessage), blockedReference?.state?.operatorMessage), check("Core icons rendered", ["estop", "power", "mode-manual", "mode-auto", "RUN", "STOP", "HOME", "toggle-flood", "toggle-mist"].every((action) => buttons[action]?.iconName), JSON.stringify(buttons)), check("Spindle/coolant state updates", controls?.state?.spindle?.direction === "stop" && controls?.state?.coolant?.mist === true, JSON.stringify(controls?.state)), check("NML command diagnostic", diagnostics?.dom?.gmoccapyNativeCommand?.includes("NML emcCommand"), diagnostics?.dom?.gmoccapyNativeCommand), check("Web runtime diagnostic", diagnostics?.dom?.gmoccapyWebPath?.includes("store.dispatch"), diagnostics?.dom?.gmoccapyWebPath), check("HAL postgui diagnostic", diagnostics?.dom?.gmoccapyHalPostgui?.includes("tool-change-loop"), diagnostics?.dom?.gmoccapyHalPostgui), check("Screenshots are nonblank", report.steps.every((step) => step.pixelStats.nonBlackRatio > 0.1), report.steps.map((step) => `${step.name}:${step.pixelStats.nonBlackRatio}`).join(", ")), ); } function summarizeState(state = {}) { return { machineProfile: state.machineProfile, coordinates: state.profile?.traj?.coordinates, profileTcpCapable: state.profile?.tcpCapable, profileRtcpProof: state.profile?.rtcpProof, runState: state.runState, rtcpState: state.rtcpState, kinsType: state.kinsType, machine: { powerOn: state.machine?.powerOn, taskState: state.machine?.taskState, mode: state.machine?.mode, allHomed: state.machine?.allHomed, interpState: state.machine?.interpState, }, spindle: { enabled: state.spindle?.enabled, direction: state.spindle?.direction, rpm: state.spindle?.rpm, }, coolant: { flood: state.coolant?.flood, mist: state.coolant?.mist, }, taskPolicy: { canRunAuto: state.linuxCncTaskPolicy?.canRunAuto, canRunAutoStrict: state.linuxCncTaskPolicy?.canRunAutoStrict, iniLoaded: state.linuxCncTaskPolicy?.iniLoaded, machineFileStaged: state.linuxCncTaskPolicy?.machineFileStaged, taskHalRuntimeReady: state.linuxCncTaskPolicy?.taskHalRuntimeReady, }, 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", ".png": "image/png", ".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 check(name, pass, detail) { return { name, pass: Boolean(pass), detail: String(detail ?? "-") }; } function isReferenceRunBlockedReason(message) { return [ "run blocked: LinuxCNC INI not loaded", "run blocked: LinuxCNC machine files not staged", "run blocked: no machine-file G-code opened for task/HAL session", "run blocked: task/HAL runtime not ready", ].includes(String(message || "")); } function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }