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 EVIDENCE_SCOPE = "working7-manual-flow-evidence"; const SCREENSHOT_DIR = path.join(OUTPUT_DIR, EVIDENCE_SCOPE); 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 || `w7-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`; const REPORT_ID = process.env.REPORT_ID || `report-${JOB_ID}`; const REPORT_BASENAME = `${EVIDENCE_SCOPE}-report`; 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 = []; const networkErrors = []; page.on("console", (msg) => { if (msg.type() === "error") consoleErrors.push(msg.text()); }); page.on("pageerror", (error) => pageErrors.push(error.message)); page.on("response", (response) => { if (response.status() >= 400) { networkErrors.push(`${response.status()} ${response.url()}`); } }); 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, networkErrors, }; try { 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(() => document.querySelector("[data-five-axis-canvas]")?.dataset?.threeReady === "true", { timeout: 20000 }); await windowReady(); await captureStep("01-loaded", "Main shell loaded", "The gmoccapy shell, canvas, diagnostics, and public store API are ready."); await click("power"); await waitForState((state) => state.machine.powerOn === true && state.machine.taskState === "on", 10000, "power on"); await click("HOME"); await waitForState((state) => state.machine.allHomed === true && state.machine.mode === "manual", 10000, "home complete"); await captureStep("02-powered-homed-manual", "POWER and HOME", "Machine is powered, homed, and in MANUAL mode."); await click("mode-auto"); await waitForState((state) => state.machine.mode === "auto" && state.machine.powerOn === true && state.machine.allHomed === true, 10000, "auto mode"); await captureStep("03-auto-active", "AUTO mode active", "AUTO mode becomes active while power and home state are preserved."); await click("mode-manual"); await waitForState((state) => state.machine.mode === "manual" && state.machine.powerOn === true, 10000, "manual mode"); const beforeJog = await getState(); await click("JOG_X_POS"); await waitForState((state) => Number(state.axisPose?.x || 0) > Number(beforeJog.axisPose?.x || 0), 10000, "X+ jog changes position"); await captureStep("04-manual-jog-x", "Manual X+ jog", "X+ jog changes the X axis position in MANUAL mode."); await click("mode-mdi"); await waitForState((state) => state.machine.mode === "mdi", 10000, "mdi mode"); await setMdiCommand("G90 X12.5 Y-4 Z1.25 F900"); await submitMdiCommand(); await waitForState((state) => ( state.machine.mode === "mdi" && /G90 X12\.5 Y-4 Z1\.25 F900/.test(state.machine.mdiCommand || state.operatorMessage || "") ), 10000, "MDI command accepted"); await captureStep("05-mdi-command", "MDI command", "MDI accepts a coordinate command and records the staged/executed command state."); await click("mode-manual"); await waitForState((state) => state.machine.mode === "manual", 10000, "manual before overrides"); await click("rapid-override-up"); await waitForState((state) => Number(state.feed.rapidOverride) === 110, 8000, "rapid override up"); await click("rapid-override-reset"); await waitForState((state) => Number(state.feed.rapidOverride) === 100, 8000, "rapid override reset"); await click("feed-override-down"); await waitForState((state) => Number(state.feed.feedOverride) === 90, 8000, "feed override down"); await click("feed-override-reset"); await waitForState((state) => Number(state.feed.feedOverride) === 100, 8000, "feed override reset"); await click("ignore-limits"); await waitForState((state) => state.gmoccapyGui.ignoreLimits === true, 8000, "ignore limits on"); await click("block-delete"); await waitForState((state) => state.gmoccapyGui.optionalBlocks === true, 8000, "block delete on"); await click("optional-stop"); await waitForState((state) => state.gmoccapyGui.optionalStop === true, 8000, "optional stop on"); await captureStep("06-overrides-hal", "Overrides and HAL inputs", "Rapid/feed overrides reset to 100 and HAL input toggles are active."); await click("spindle-forward"); await waitForState((state) => state.spindle.enabled === true && state.spindle.direction === "forward", 8000, "spindle forward"); await click("spindle-override-up"); await waitForState((state) => Number(state.spindle.override) === 110, 8000, "spindle override up"); await click("spindle-override-reset"); await waitForState((state) => Number(state.spindle.override) === 100, 8000, "spindle override reset"); await click("toggle-flood"); await waitForState((state) => state.coolant.flood === true, 8000, "flood toggled on"); await click("toggle-mist"); await waitForState((state) => state.coolant.mist === true, 8000, "mist toggled on"); await click("spindle-stop"); await waitForState((state) => state.spindle.enabled === false && state.spindle.direction === "stop", 8000, "spindle stop"); await captureStep("07-spindle-coolant", "Spindle and coolant", "Spindle, spindle override, flood, and mist controls obey the powered machine gate."); const savedSnapshot = await page.evaluate(() => window.webRtcp5AxisSimulation.saveSession({ sessionId: "working7-manual-flow", filename: "working7-session.json", })); await waitForState((state) => state.sessionPersistence.status === "saved", 10000, "session saved"); await page.select('[data-action="select-profile"]', "gmoccapy-xyzab"); await waitForState((state) => state.machineProfile === "gmoccapy-xyzab", 15000, "profile changed before restore"); await page.evaluate(() => window.webRtcp5AxisSimulation.restoreSession({ sessionId: "working7-manual-flow", filename: "working7-session.json", })); await waitForState((state) => state.sessionPersistence.status === "restored" && state.machineProfile === "xyzac-trt", 15000, "session restored"); const restoredSession = await getState(); report.sessionEvidence = { savedPath: savedSnapshot.path, savedStorageMode: savedSnapshot.storageMode, restoredPath: restoredSession.sessionPersistence.path, restoredStorageMode: restoredSession.sessionPersistence.storageMode, }; await captureStep("08-session-restored", "Save and restore session", "Saved profile and control state are restored after switching to another profile."); await captureStep("09-diagnostics", "Diagnostics", "Task policy, INI, Task/HAL, full boundary, gmoccapy communication, and HAL diagnostics are visible."); addChecks(); report.status = report.checks.every((check) => check.pass) && pageErrors.length === 0 ? "PASS" : "FAIL"; const jsonPath = path.join(OUTPUT_DIR, `${REPORT_BASENAME}.json`); const pdfPath = path.join(OUTPUT_DIR, `${REPORT_BASENAME}.pdf`); report.jsonPath = jsonPath; report.pdfPath = pdfPath; await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); await writePdfReport(pdfPath, report); console.log(`working7_manual_flow_status=${report.status}`); console.log(`working7_manual_flow_job_id=${report.jobId}`); console.log(`working7_manual_flow_report_id=${report.reportId}`); console.log(`working7_manual_flow_json=${jsonPath}`); console.log(`working7_manual_flow_pdf=${pdfPath}`); console.log(`working7_manual_flow_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 && state.machineFileStaging?.status === "staged" ), 30000, "runtime and machine files ready"); } 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), disabled: Boolean(element?.disabled), active: element?.dataset?.active || null, commandReady: element?.dataset?.commandReady || null, title: element?.getAttribute("title") || "", }; }; return { regions: window.webRtcp5AxisSimulation.getRegions?.() || null, canvas: { ...(document.querySelector("[data-five-axis-canvas]")?.dataset || {}) }, sidebar: { power: button("power"), manual: button("mode-manual"), auto: button("mode-auto"), mdi: button("mode-mdi"), }, bottom: { home: button("HOME"), jogXPlus: button("JOG_X_POS"), mdiRun: button("MDI_RUN"), }, values: { rapidOverride: text('[data-value="rapid-override"]'), feedOverride: text('[data-value="feed-override"]'), spindleOverride: text('[data-value="spindle-override"]'), halLast: text('[data-value="gmoccapy-hal-last"]'), session: text('[data-session-persistence="status"]'), taskGates: text('[data-linuxcnc-task-policy="gates"]'), taskHal: text('[data-task-hal-runtime="readiness"]'), gmoccapyHal: text('[data-gmoccapy-hal="boundary"]'), operatorMessage: text("[data-operator-message]"), }, }; }); } async function setMdiCommand(command) { await page.$eval('[data-action="mdi-command"]', (input, value) => { input.value = value; input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); }, command); await waitForState((state) => state.machine.mdiCommand === command.toUpperCase(), 5000, "MDI input staged"); } async function submitMdiCommand() { await page.$eval('[data-action="mdi-form"]', (form) => { form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); }); } async function click(action) { await page.evaluate((selector) => { const element = document.querySelector(selector); if (!element) throw new Error(`missing action ${selector}`); element.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 loaded = byName["01-loaded"]?.state; const manual = byName["02-powered-homed-manual"]?.state; const auto = byName["03-auto-active"]?.state; const jog = byName["04-manual-jog-x"]?.state; const mdi = byName["05-mdi-command"]?.state; const overrides = byName["06-overrides-hal"]?.state; const spindleCoolant = byName["07-spindle-coolant"]?.state; const restored = byName["08-session-restored"]?.state; const diagnostics = byName["09-diagnostics"]?.dom; report.checks.push( check("Main shell regions and canvas are ready", Object.values(byName["01-loaded"]?.dom?.regions || {}).every(Boolean) && byName["01-loaded"]?.dom?.canvas?.threeReady === "true", JSON.stringify({ regions: byName["01-loaded"]?.dom?.regions, canvas: byName["01-loaded"]?.dom?.canvas })), check("POWER and HOME leave MANUAL ready", manual?.machine?.powerOn === true && manual?.machine?.allHomed === true && manual?.machine?.mode === "manual", JSON.stringify(manual?.machine)), check("AUTO activates without losing power/home", auto?.machine?.mode === "auto" && auto?.machine?.powerOn === true && auto?.machine?.allHomed === true, JSON.stringify(auto?.machine)), check("Manual jog changes X", Number(jog?.axisPose?.x || 0) > Number(manual?.axisPose?.x || 0), JSON.stringify({ before: manual?.axisPose, after: jog?.axisPose })), check("MDI command is accepted in MDI mode", mdi?.machine?.mode === "mdi" && /G90 X12\.5 Y-4 Z1\.25 F900/.test(`${mdi?.machine?.mdiCommand || ""} ${mdi?.operatorMessage || ""}`), JSON.stringify({ machine: mdi?.machine, operatorMessage: mdi?.operatorMessage })), check("Overrides reset and HAL toggles are active", overrides?.feed?.rapidOverride === 100 && overrides?.feed?.feedOverride === 100 && overrides?.gmoccapyGui?.ignoreLimits === true && overrides?.gmoccapyGui?.optionalBlocks === true && overrides?.gmoccapyGui?.optionalStop === true, JSON.stringify({ feed: overrides?.feed, gmoccapyGui: overrides?.gmoccapyGui })), check("Spindle and coolant controls update state", spindleCoolant?.spindle?.direction === "stop" && spindleCoolant?.spindle?.override === 100 && spindleCoolant?.coolant?.flood === true && spindleCoolant?.coolant?.mist === true, JSON.stringify({ spindle: spindleCoolant?.spindle, coolant: spindleCoolant?.coolant })), check("Session restore returns to saved profile", restored?.machineProfile === "xyzac-trt" && restored?.sessionPersistence?.status === "restored", JSON.stringify({ machineProfile: restored?.machineProfile, sessionPersistence: restored?.sessionPersistence, evidence: report.sessionEvidence })), check("Diagnostics expose Task/HAL and gmoccapy HAL", Boolean(diagnostics?.values?.taskHal) && Boolean(diagnostics?.values?.gmoccapyHal), JSON.stringify(diagnostics?.values)), check("Screenshots are nonblank", report.steps.every((step) => step.pixelStats.nonBlackRatio > 0.1), report.steps.map((step) => `${step.name}:${step.pixelStats.nonBlackRatio}`).join(", ")), ); } 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({ machineProfile: step.state.machineProfile, runState: step.state.runState, machine: step.state.machine, axisPose: step.state.axisPose, feed: step.state.feed, spindle: step.state.spindle, coolant: step.state.coolant, sessionPersistence: step.state.sessionPersistence, }))}

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

working7 Manual Flow 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(); } function summarizeState(state = {}) { return { machineProfile: state.machineProfile, 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, mdiCommand: state.machine?.mdiCommand, }, axisPose: pickAxes(state.axisPose), dro: pickAxes(state.dro), feed: { rapidOverride: Number(state.feed?.rapidOverride), feedOverride: Number(state.feed?.feedOverride), feedRate: Number(state.feed?.feedRate), currentVelocity: Number(state.feed?.currentVelocity), }, spindle: { enabled: Boolean(state.spindle?.enabled), direction: state.spindle?.direction, override: Number(state.spindle?.override), rpm: Number(state.spindle?.rpm), }, coolant: { flood: Boolean(state.coolant?.flood), mist: Boolean(state.coolant?.mist), }, gmoccapyGui: { ignoreLimits: Boolean(state.gmoccapyGui?.ignoreLimits), optionalBlocks: Boolean(state.gmoccapyGui?.optionalBlocks), optionalStop: Boolean(state.gmoccapyGui?.optionalStop), lastHalPinEffect: state.gmoccapyGui?.lastHalPinEffect, }, mdiHistory: state.mdiHistory || [], sessionPersistence: { status: state.sessionPersistence?.status, storageMode: state.sessionPersistence?.storageMode, path: state.sessionPersistence?.path, savedAt: state.sessionPersistence?.savedAt, restoredAt: state.sessionPersistence?.restoredAt, }, machineFileStaging: { status: state.machineFileStaging?.status, fileCount: state.machineFileStaging?.fileCount, selectedGcodeSourceRel: state.machineFileStaging?.selectedGcodeSourceRel, }, taskHalRuntimeReadiness: { loaded: Boolean(state.taskHalRuntimeReadiness?.loaded), halSyncReady: Boolean(state.taskHalRuntimeReadiness?.halSyncReady), }, 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)); }