完善五轴 RTCP 仿真与验证资料

This commit is contained in:
2026-07-01 21:49:58 -04:00
parent ac4e855b2b
commit d0d58998ac
159 changed files with 6771594 additions and 339 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,550 @@
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) => `
<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({
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,
}))}</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>working7 Manual Flow 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();
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

View File

@@ -4,7 +4,9 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"evidence:working7": "node capture-working7-manual-flow-evidence.mjs",
"evidence:working7:full": "node capture-working7-full-functional-evidence.mjs",
"test": "node run-site-test.mjs"
},
"keywords": [],
"author": "",

View File

@@ -1,17 +1,72 @@
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import puppeteer from "puppeteer-core";
import { PNG } from "pngjs";
const REPO_ROOT = path.resolve("/home/meswork/cnc_wams");
const ROOT = path.resolve("/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test");
const OUTPUT_DIR = path.join(ROOT, "output");
const SCREENSHOT_DIR = path.join(ROOT, "screenshots");
const URL = "https://82.156.24.101:8092/";
const CHROME_PATH = "/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 CHROME_PATH = process.env.CHROME_PATH || process.env.CHROMIUM || "/usr/bin/google-chrome";
const localProgramPath = path.join(ROOT, "fixtures", "test-program.ngc");
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const MIME_TYPES = {
".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",
".png": "image/png",
".svg": "image/svg+xml",
".wasm": "application/wasm",
".xml": "application/xml; charset=utf-8",
};
function contentTypeFor(filePath) {
return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
}
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 stat = await fs.stat(targetPath).catch(() => null);
let filePath = targetPath;
if (stat?.isDirectory()) {
filePath = path.join(targetPath, "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));
}
});
}
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.mkdir(SCREENSHOT_DIR, { recursive: true });
await fs.mkdir(path.dirname(localProgramPath), { recursive: true });
@@ -20,6 +75,17 @@ const findings = [];
const consoleLogs = [];
const pageErrors = [];
const requestFailures = [];
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 local static server");
}
targetUrl = `http://127.0.0.1:${address.port}${APP_URL}`;
}
const browser = await puppeteer.launch({
headless: true,
@@ -83,7 +149,7 @@ try {
"",
].join("\n"), "utf8");
await page.goto(URL, { waitUntil: "networkidle2", timeout: 60000 });
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(() => {
@@ -140,6 +206,34 @@ try {
).catch(() => null);
}
async function waitForTaskHalReady(timeoutMs = 30000) {
return waitForState((state) => (
state.taskHalRuntimeReadiness?.loaded === true &&
state.taskHalRuntimeReadiness?.taskRuntimeReady === true &&
state.taskHalRuntimeReadiness?.motionRuntimeReady === true &&
state.taskHalRuntimeReadiness?.halRuntimeReady === true &&
state.taskHalRuntimeReadiness?.halSyncReady === true &&
!state.taskHalExecutionPending &&
!state.interpreterExecutionPending
), timeoutMs, "Task/HAL ready and app idle").catch(() => null);
}
async function clickJogAndWait(action, axis, direction, beforeValue) {
const selector = `[data-action="${action}"]`;
await waitForTaskHalReady(30000);
for (let attempt = 0; attempt < 2; attempt += 1) {
await clickAndWait(selector, 900);
const changed = await waitForState((nextState) => {
const nextValue = Number(nextState.axisPose?.[axis]);
return direction > 0
? nextValue > Number(beforeValue)
: nextValue < Number(beforeValue);
}, 6000, `${action} axis change`).catch(() => null);
if (changed) return changed;
}
return getState();
}
async function getSummary() {
return page.evaluate(() => {
const state = window.webRtcp5AxisSimulation.getState();
@@ -179,12 +273,31 @@ try {
}
async function clickAndWait(selector, waitMs = 600) {
await page.click(selector);
await page.waitForSelector(selector, { timeout: 10000 });
await page.waitForFunction((targetSelector) => {
const element = document.querySelector(targetSelector);
if (!element) return false;
const ariaDisabled = element.getAttribute("aria-disabled") === "true";
const commandReady = element.getAttribute("data-command-ready") === "false";
return !element.disabled && !ariaDisabled && !commandReady;
}, { timeout: 15000 }, selector);
await page.evaluate((targetSelector) => {
const element = document.querySelector(targetSelector);
if (!element) {
throw new Error(`missing click target ${targetSelector}`);
}
if (element.disabled || element.getAttribute("aria-disabled") === "true") {
throw new Error(`disabled click target ${targetSelector}`);
}
element.scrollIntoView({ block: "center", inline: "center" });
element.click();
}, selector);
await sleep(waitMs);
await waitForAppIdle();
}
async function setInputValue(selector, value) {
await page.waitForSelector(selector, { timeout: 10000 });
await page.$eval(selector, (el, nextValue) => {
el.value = nextValue;
el.dispatchEvent(new Event("input", { bubbles: true }));
@@ -193,6 +306,21 @@ try {
await sleep(400);
}
async function selectValue(selector, value) {
await page.waitForSelector(selector, { timeout: 10000 });
await page.evaluate((targetSelector, nextValue) => {
const element = document.querySelector(targetSelector);
if (!element) {
throw new Error(`missing select target ${targetSelector}`);
}
element.value = nextValue;
element.dispatchEvent(new Event("input", { bubbles: true }));
element.dispatchEvent(new Event("change", { bubbles: true }));
}, selector, value);
await sleep(400);
await waitForAppIdle();
}
function classify(condition, passText, failText) {
return condition ? { status: "PASS", text: passText } : { status: "FAIL", text: failText };
}
@@ -253,10 +381,11 @@ try {
initialBoundaryStatus.status,
);
const taskHalReadyState = await waitForTaskHalReady(30000);
const initialTaskHalStatus = classifyWarn(
!/pending|blocked/i.test(initial.taskHal),
`Task/HAL 已就绪:${initial.taskHal}`,
`Task/HAL 未完成就绪:${initial.taskHal}`,
Boolean(taskHalReadyState),
`Task/HAL 已就绪:${taskHalReadyState?.taskHalRuntimeReadiness?.semanticBoundary || initial.taskHal}`,
`Task/HAL 冷启动时尚未完成就绪:${initial.taskHal}`,
);
await recordResult(
"initial-task-hal",
@@ -290,6 +419,7 @@ try {
await clickAndWait('[data-action="HOME"]');
await waitForState((nextState) => nextState.machine.allHomed === true, 8000, "machine homed").catch(() => null);
await waitForTaskHalReady(30000);
state = await getState();
await recordResult(
"home",
@@ -300,8 +430,7 @@ try {
);
const beforeJogX = state.axisPose.x;
await clickAndWait('[data-action="JOG_X_POS"]');
state = await getState();
state = await clickJogAndWait("JOG_X_POS", "x", 1, beforeJogX);
await recordResult(
"jog-x-plus",
"JOG X+",
@@ -311,8 +440,7 @@ try {
);
const beforeJogY = state.axisPose.y;
await clickAndWait('[data-action="JOG_Y_NEG"]');
state = await getState();
state = await clickJogAndWait("JOG_Y_NEG", "y", -1, beforeJogY);
await recordResult(
"jog-y-minus",
"JOG Y-",
@@ -332,6 +460,9 @@ try {
state.machine.mode === "auto" ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="mode-manual"]');
await waitForState((nextState) => nextState.machine.mode === "manual" && nextState.machine.interpState === "idle", 10000, "MANUAL idle before MDI").catch(() => null);
await waitForTaskHalReady(30000);
await clickAndWait('[data-action="mode-mdi"]');
await waitForState((nextState) => nextState.machine.mode === "mdi", 8000, "MDI mode").catch(() => null);
state = await getState();
@@ -343,7 +474,17 @@ try {
state.machine.mode === "mdi" ? "PASS" : "FAIL",
);
if (state.machine.mode !== "mdi") {
await clickAndWait('[data-action="mode-manual"]');
await waitForState((nextState) => nextState.machine.mode === "manual", 10000, "retry manual before MDI").catch(() => null);
await waitForTaskHalReady(30000);
await clickAndWait('[data-action="mode-mdi"]');
await waitForState((nextState) => nextState.machine.mode === "mdi", 10000, "retry MDI mode").catch(() => null);
state = await getState();
}
await setInputValue('[data-action="mdi-command"]', "M428");
await waitForState((nextState) => nextState.machine.mode === "mdi", 10000, "MDI active before M428");
await clickAndWait('[data-action="mdi-submit"]', 800);
state = await getState();
await recordResult(
@@ -493,7 +634,7 @@ try {
fullOn && stateAfterFullOff.preview.fullscreen === false ? "PASS" : "FAIL",
);
await page.select('[data-action="select-profile"]', "xyzbc-trt");
await selectValue('[data-action="select-profile"]', "xyzbc-trt");
await sleep(2000);
state = await getState();
await recordResult(
@@ -505,7 +646,7 @@ try {
);
await capture("02-profile-xyzbc");
await page.select('[data-action="select-profile"]', "xyzac-trt");
await selectValue('[data-action="select-profile"]', "xyzac-trt");
await sleep(2000);
state = await getState();
await recordResult(
@@ -534,7 +675,7 @@ try {
if (stagedCount > 0) {
const sourceRel = summary.state.machineFileStaging.gcodeSources[0].sourceRel;
await page.select('[data-action="select-linuxcnc-gcode-source"]', sourceRel);
await selectValue('[data-action="select-linuxcnc-gcode-source"]', sourceRel);
await sleep(4000);
summary = await getSummary();
const loadedVendored = summary.state.programSource === "linuxcnc-vendored-5axis-gcode";
@@ -604,9 +745,13 @@ try {
await recordResult(
"step-program",
"Step 单步执行",
"点击 Step 后 runState=steppingactiveLine 前进或保持受控",
`runState=${state.runState}, activeLine=${state.activeLine}`,
state.runState === "stepping" || state.activeLine !== beforeStepLine ? "PASS" : "FAIL",
"点击 Step 后 Task/HAL 记录 singleStepping=true并保持暂停态等待后续操作",
`runState=${state.runState}, activeLine=${state.activeLine}, taskPaused=${state.machine.taskPaused}, singleStepping=${state.taskHalStatus?.task?.singleStepping}`,
state.machine.taskPaused === true
&& state.taskHalStatus?.task?.singleStepping === true
&& Number(state.activeLine) >= Number(beforeStepLine)
? "PASS"
: "FAIL",
);
await clickAndWait('[data-action="STOP"]', 1200);
@@ -657,7 +802,10 @@ try {
saveSessionStatus.status,
);
await clickAndWait('[data-action="mode-manual"]');
await waitForState((nextState) => nextState.machine.mode === "manual", 8000, "manual mode before session mutation").catch(() => null);
await clickAndWait('[data-action="JOG_X_POS"]');
await waitForState((nextState) => Number(nextState.axisPose.x) !== Number(state.axisPose.x), 8000, "axis changed before restore").catch(() => null);
const modifiedState = await getState();
await clickAndWait('[data-action="RESTORE_SESSION"]', 2500);
state = await getState();
@@ -712,7 +860,7 @@ try {
const report = {
generatedAt: new Date().toISOString(),
targetUrl: URL,
targetUrl,
chromePath: CHROME_PATH,
screenshots,
screenshotAnalysis,
@@ -722,10 +870,20 @@ try {
pageErrors,
requestFailures,
};
report.status = findings.some((finding) => finding.status === "FAIL") || pageErrors.length > 0
? "FAIL"
: "PASS";
await fs.writeFile(path.join(OUTPUT_DIR, "site-test-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(`site_test_status=${report.status}`);
console.log(`site_test_target_url=${targetUrl}`);
console.log(`site_test_report=/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test/output/site-test-report.json`);
if (report.status !== "PASS") {
process.exitCode = 1;
}
} finally {
await browser.close();
await browser.close().catch(() => {});
if (server) await new Promise((resolve) => server.close(resolve));
}
async function analyzeScreenshot(filePath) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 247 KiB