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(step.description)}
Screenshot: ${escapeHtml(step.screenshotPath)}
State: ${escapeHtml(JSON.stringify(step.state))}
| Check | Status | Evidence |
|---|