完善 gmoccapy XYZAB 参考功能
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
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) => `
|
||||
<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(step.state))}</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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>gmoccapy XYZAB Function 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 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, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
BIN
qa/web-rtcp-5axis-site-test/output/gmoccapy-xyzab/01-loaded.png
Normal file
BIN
qa/web-rtcp-5axis-site-test/output/gmoccapy-xyzab/01-loaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 235 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Reference in New Issue
Block a user