完善五轴 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

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) {