494 lines
19 KiB
JavaScript
494 lines
19 KiB
JavaScript
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) => `
|
|
<tr>
|
|
<td>${escapeHtml(item.name)}</td>
|
|
<td class="${item.pass ? "pass" : "fail"}">${item.pass ? "PASS" : "FAIL"}</td>
|
|
<td>${escapeHtml(item.detail)}</td>
|
|
</tr>
|
|
`).join("");
|
|
const steps = data.steps.map((step) => `
|
|
<section>
|
|
<h2>${escapeHtml(step.name)} - ${escapeHtml(step.title)}</h2>
|
|
<p>${escapeHtml(step.description)}</p>
|
|
<p><strong>Screenshot:</strong> ${escapeHtml(step.screenshotPath)}</p>
|
|
<p><strong>State:</strong> ${escapeHtml(JSON.stringify({
|
|
runState: step.state.runState,
|
|
machine: step.state.machine,
|
|
task: step.state.taskHalStatus?.task,
|
|
motion: step.state.taskHalStatus?.motionStatus?.motion,
|
|
}))}</p>
|
|
</section>
|
|
`).join("");
|
|
await reportPage.setContent(`<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 28px; color: #17202a; }
|
|
h1 { font-size: 22px; margin-bottom: 6px; }
|
|
h2 { font-size: 16px; margin-top: 18px; }
|
|
table { border-collapse: collapse; width: 100%; margin-top: 14px; }
|
|
th, td { border: 1px solid #9aa5b1; padding: 6px; font-size: 11px; vertical-align: top; }
|
|
th { background: #eef2f7; }
|
|
.pass { color: #126b37; font-weight: 700; }
|
|
.fail { color: #a61b1b; font-weight: 700; }
|
|
.meta { font-size: 12px; line-height: 1.45; }
|
|
section { break-inside: avoid; border-top: 1px solid #d8dee6; padding-top: 8px; }
|
|
code { font-family: Consolas, monospace; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Web RTCP 5 Axis Button Control Evidence</h1>
|
|
<div class="meta">
|
|
<div><strong>Status:</strong> ${escapeHtml(data.status)}</div>
|
|
<div><strong>Job ID:</strong> ${escapeHtml(data.jobId)}</div>
|
|
<div><strong>Report ID:</strong> ${escapeHtml(data.reportId)}</div>
|
|
<div><strong>Target:</strong> ${escapeHtml(data.targetUrl)}</div>
|
|
<div><strong>Generated:</strong> ${escapeHtml(data.generatedAt)}</div>
|
|
<div><strong>Screenshots:</strong> ${escapeHtml(data.screenshotsDir)}</div>
|
|
</div>
|
|
<h2>Checks</h2>
|
|
<table>
|
|
<thead><tr><th>Check</th><th>Status</th><th>Evidence</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
<h2>Steps</h2>
|
|
${steps}
|
|
</body>
|
|
</html>`, { 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, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function wait(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|