完善五轴 RTCP 仿真与验证资料
This commit is contained in:
@@ -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=stepping,activeLine 前进或保持受控",
|
||||
`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) {
|
||||
|
||||
Reference in New Issue
Block a user