Files
cnc_wams/qa/web-rtcp-5axis-site-test/run-site-test.mjs

911 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 });
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,
executablePath: CHROME_PATH,
defaultViewport: { width: 1600, height: 1200, deviceScaleFactor: 1 },
ignoreHTTPSErrors: true,
args: [
"--ignore-certificate-errors",
"--disable-gpu",
"--enable-webgl",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--no-sandbox",
],
});
let page;
try {
page = await browser.newPage();
page.on("console", async (msg) => {
let text = msg.text();
if (msg.type() === "error" && msg.args().length > 0) {
try {
const values = await Promise.all(msg.args().map((arg) => arg.jsonValue().catch(() => null)));
const serialized = values.filter((value) => value !== null);
if (serialized.length > 0) {
text = `${text} ${JSON.stringify(serialized)}`;
}
} catch {}
}
consoleLogs.push({
type: msg.type(),
text,
location: msg.location(),
});
});
page.on("pageerror", (error) => {
pageErrors.push({
message: error.message,
stack: error.stack || "",
});
});
page.on("requestfailed", (request) => {
requestFailures.push({
url: request.url(),
method: request.method(),
errorText: request.failure()?.errorText || "unknown",
resourceType: request.resourceType(),
});
});
await fs.writeFile(localProgramPath, [
"%",
"G90 G17 G21",
"G0 X0 Y0 Z5",
"G1 Z-1 F200",
"G1 X10 Y10 F300",
"M30",
"%",
"",
].join("\n"), "utf8");
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(() => {
const state = window.webRtcp5AxisSimulation?.getState?.();
return Boolean(state?.iniConfigReadiness?.loaded);
}, { timeout: 20000 }).catch(() => {});
await waitForAppIdle(20000);
await waitForState(
(state) => state.taskHalRuntimeReadiness?.halSyncReady === true,
20000,
"task/HAL runtime readiness",
).catch(() => null);
const screenshots = {};
async function capture(name, clipSelector = null) {
const targetPath = path.join(SCREENSHOT_DIR, `${name}.png`);
if (clipSelector) {
const element = await page.$(clipSelector);
if (element) {
await element.screenshot({ path: targetPath });
} else {
await page.screenshot({ path: targetPath, fullPage: true });
}
} else {
await page.screenshot({ path: targetPath, fullPage: true });
}
screenshots[name] = targetPath;
return targetPath;
}
async function getState() {
return page.evaluate(() => {
const state = window.webRtcp5AxisSimulation.getState();
return JSON.parse(JSON.stringify(state));
});
}
async function waitForState(predicate, timeoutMs = 10000, label = "state condition") {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const snapshot = await getState();
if (predicate(snapshot)) return snapshot;
await sleep(100);
}
throw new Error(`Timed out waiting for ${label}`);
}
async function waitForAppIdle(timeoutMs = 8000) {
await waitForState(
(state) => !state.taskHalExecutionPending && !state.interpreterExecutionPending,
timeoutMs,
"app idle",
).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();
const regions = window.webRtcp5AxisSimulation.getRegions?.() || null;
const machineSummary = document.querySelector('[data-machine-state="summary"]')?.textContent?.trim() || "";
const frameBoundary = document.querySelector('[data-rtcp-diagnostic="boundary"]')?.textContent?.trim() || "";
const boundaryReady = document.querySelector('[data-linuxcnc-boundary="readiness"]')?.textContent?.trim() || "";
const taskHal = document.querySelector('[data-task-hal-runtime="readiness"]')?.textContent?.trim() || "";
const machineFiles = document.querySelector('[data-machine-file-staging="status"]')?.textContent?.trim() || "";
const activeLine = document.querySelector('[data-active-program-line]')?.textContent?.trim() || "";
const linuxCncSourceStatus = document.querySelector('[data-linuxcnc-gcode-source="status"]')?.textContent?.trim() || "";
const canvas = document.querySelector("[data-five-axis-canvas]");
return {
regions,
machineSummary,
frameBoundary,
boundaryReady,
taskHal,
machineFiles,
activeLine,
linuxCncSourceStatus,
canvas: canvas ? { ...canvas.dataset } : null,
state,
};
});
}
async function recordResult(key, title, expectation, actual, status, detail = "") {
findings.push({
key,
title,
expectation,
actual,
status,
detail,
});
}
async function clickAndWait(selector, waitMs = 600) {
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 }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}, value);
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 };
}
function classifyWarn(condition, passText, warnText) {
return condition ? { status: "PASS", text: passText } : { status: "WARN", text: warnText };
}
const initial = await getSummary();
await capture("01-home");
const regionsPass = [
"titlebar",
"preview",
"dro",
"gcode",
"status-sidebar",
"info-tabs",
"override",
"spindle-coolant",
"bottom-controls",
].every((region) => initial.regions?.[region] === true);
const regionsStatus = classify(regionsPass, "9个主区域全部渲染", "存在主区域未渲染");
await recordResult(
"layout-regions",
"主界面九大区域渲染",
"titlebar/preview/dro/gcode/sidebar/info/override/spindle/bottom 全部存在",
regionsStatus.text,
regionsStatus.status,
);
const canvasFallback = initial.canvas?.threeRenderer === "2d-fallback";
const canvasReady = initial.canvas?.threeReady === "true";
const canvasStatus = canvasReady
? (canvasFallback
? { status: "WARN", text: `预览可用,但当前测试环境使用 ${initial.canvas.threeRenderer},原因:${initial.canvas.threeFallbackReason || "-"}` }
: { status: "PASS", text: `预览已启用 ${initial.canvas.threeRenderer}` })
: { status: "FAIL", text: "预览画布未进入 ready 状态" };
await recordResult(
"preview-canvas",
"预览画布初始化",
"预览区域可渲染机床与路径",
canvasStatus.text,
canvasStatus.status,
initial.canvas ? JSON.stringify(initial.canvas) : "missing canvas dataset",
);
const initialBoundaryStatus = classify(
initial.frameBoundary.includes("linuxcnc_kinematics_wasm_c_abi"),
`RTCP 帧边界已接入 LinuxCNC 运动学:${initial.frameBoundary}`,
`RTCP 帧仍处于降级边界:${initial.frameBoundary}`,
);
await recordResult(
"initial-boundary",
"首屏 RTCP/运动学边界",
"首屏应优先使用 LinuxCNC/WASM 运动学边界",
initialBoundaryStatus.text,
initialBoundaryStatus.status,
);
const taskHalReadyState = await waitForTaskHalReady(30000);
const initialTaskHalStatus = classifyWarn(
Boolean(taskHalReadyState),
`Task/HAL 已就绪:${taskHalReadyState?.taskHalRuntimeReadiness?.semanticBoundary || initial.taskHal}`,
`Task/HAL 冷启动时尚未完成就绪:${initial.taskHal}`,
);
await recordResult(
"initial-task-hal",
"首屏 Task/HAL 运行态",
"Task/HAL readiness 应就绪",
initialTaskHalStatus.text,
initialTaskHalStatus.status,
);
await clickAndWait('[data-action="power"]');
await waitForState((nextState) => nextState.machine.powerOn === true, 8000, "machine power on").catch(() => null);
let state = await getState();
await recordResult(
"power-on",
"POWER 上电",
"点击 POWER 后 machine.powerOn=truetaskState=on",
`powerOn=${state.machine.powerOn}, taskState=${state.machine.taskState}, runState=${state.runState}`,
state.machine.powerOn && state.machine.taskState === "on" ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="mode-jog"]');
await waitForState((nextState) => nextState.machine.mode === "manual", 8000, "JOG/manual mode").catch(() => null);
state = await getState();
await recordResult(
"mode-jog",
"JOG 模式切换",
"点击 JOG 后 mode 归一到 manual",
`mode=${state.machine.mode}`,
state.machine.mode === "manual" ? "PASS" : "FAIL",
);
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",
"HOME 回参考点",
"点击 HOME 后 allHomed=truerunState=idle",
`allHomed=${state.machine.allHomed}, runState=${state.runState}`,
state.machine.allHomed && state.runState === "idle" ? "PASS" : "FAIL",
);
const beforeJogX = state.axisPose.x;
state = await clickJogAndWait("JOG_X_POS", "x", 1, beforeJogX);
await recordResult(
"jog-x-plus",
"JOG X+",
"点击 X+ 后 X 坐标增加",
`before=${beforeJogX}, after=${state.axisPose.x}`,
state.axisPose.x > beforeJogX ? "PASS" : "FAIL",
);
const beforeJogY = state.axisPose.y;
state = await clickJogAndWait("JOG_Y_NEG", "y", -1, beforeJogY);
await recordResult(
"jog-y-minus",
"JOG Y-",
"点击 Y- 后 Y 坐标减小",
`before=${beforeJogY}, after=${state.axisPose.y}`,
state.axisPose.y < beforeJogY ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="mode-auto"]');
await waitForState((nextState) => nextState.machine.mode === "auto", 8000, "AUTO mode").catch(() => null);
state = await getState();
await recordResult(
"mode-auto",
"AUTO 模式切换",
"点击 AUTO 后 machine.mode=auto",
`mode=${state.machine.mode}`,
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();
await recordResult(
"mode-mdi",
"MDI 模式切换",
"点击 MDI 后 machine.mode=mdi",
`mode=${state.machine.mode}`,
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(
"mdi-m428",
"MDI 执行 M428",
"执行 M428 后 RTCP 打开kinsType 切到 tcp-*",
`rtcp=${state.rtcpState}, kinsType=${state.kinsType}, message=${state.operatorMessage}`,
state.rtcpState === "on" && String(state.kinsType).startsWith("tcp-") ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="mdi-history"][data-command="M429"]', 800);
state = await getState();
await recordResult(
"mdi-m429",
"MDI 快捷执行 M429",
"执行 M429 后 RTCP 关闭kinsType=identity",
`rtcp=${state.rtcpState}, kinsType=${state.kinsType}, message=${state.operatorMessage}`,
state.rtcpState === "off" && state.kinsType === "identity" ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="kins-tcp"]');
state = await getState();
await recordResult(
"sidebar-tcp",
"侧栏 TCP 按钮",
"点击 TCP 后 RTCP 打开",
`rtcp=${state.rtcpState}, kinsType=${state.kinsType}`,
state.rtcpState === "on" && String(state.kinsType).startsWith("tcp-") ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="kins-identity"]');
state = await getState();
await recordResult(
"sidebar-identity",
"侧栏 IDENTITY 按钮",
"点击 IDENTITY 后 RTCP 关闭",
`rtcp=${state.rtcpState}, kinsType=${state.kinsType}`,
state.rtcpState === "off" && state.kinsType === "identity" ? "PASS" : "FAIL",
);
const beforeRapid = state.feed.rapidOverride;
await clickAndWait('[data-action="rapid-override-up"]');
state = await getState();
await recordResult(
"rapid-override",
"Rapid Override 调整",
"点击 + 后 rapidOverride 增加",
`before=${beforeRapid}, after=${state.feed.rapidOverride}`,
state.feed.rapidOverride > beforeRapid ? "PASS" : "FAIL",
);
const beforeFeed = state.feed.feedOverride;
await clickAndWait('[data-action="feed-override-down"]');
state = await getState();
await recordResult(
"feed-override",
"Feed Override 调整",
"点击 - 后 feedOverride 减少",
`before=${beforeFeed}, after=${state.feed.feedOverride}`,
state.feed.feedOverride < beforeFeed ? "PASS" : "FAIL",
);
const beforeSpindle = state.spindle.override;
await clickAndWait('[data-action="spindle-override-up"]');
state = await getState();
await recordResult(
"spindle-override",
"Spindle Override 调整",
"点击 + 后 spindle.override 增加",
`before=${beforeSpindle}, after=${state.spindle.override}`,
state.spindle.override > beforeSpindle ? "PASS" : "FAIL",
);
const floodBefore = state.coolant.flood;
await clickAndWait('[data-action="toggle-flood"]');
state = await getState();
await recordResult(
"coolant-flood",
"Flood 冷却开关",
"点击 Flood 后 flood 状态切换",
`before=${floodBefore}, after=${state.coolant.flood}`,
state.coolant.flood !== floodBefore ? "PASS" : "FAIL",
);
const mistBefore = state.coolant.mist;
await clickAndWait('[data-action="toggle-mist"]');
state = await getState();
await recordResult(
"coolant-mist",
"Mist 冷却开关",
"点击 Mist 后 mist 状态切换",
`before=${mistBefore}, after=${state.coolant.mist}`,
state.coolant.mist !== mistBefore ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="view-x"]');
state = await getState();
const viewXPass = state.preview.selectedView === "x";
await recordResult(
"view-x",
"预览视角 X",
"点击 X 后 selectedView=x",
`selectedView=${state.preview.selectedView}`,
viewXPass ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="view-y"]');
state = await getState();
await recordResult(
"view-y",
"预览视角 Y",
"点击 Y 后 selectedView=y",
`selectedView=${state.preview.selectedView}`,
state.preview.selectedView === "y" ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="clear-preview"]');
state = await getState();
await recordResult(
"clear-preview",
"Clear Preview",
"点击 Clear 后 pathPoints=0",
`pathPoints=${state.preview.pathPoints}`,
state.preview.pathPoints === 0 ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="reset-view"]');
state = await getState();
await recordResult(
"reset-view",
"Fit/Reset View",
"点击 Fit 后 selectedView=iso",
`selectedView=${state.preview.selectedView}`,
state.preview.selectedView === "iso" ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="FULL"]');
state = await getState();
const fullOn = state.preview.fullscreen === true;
await clickAndWait('[data-action="FULL"]');
const stateAfterFullOff = await getState();
await recordResult(
"fullscreen-toggle",
"Full 全屏切换",
"连续点击两次 Full 后 fullscreen true 再 false",
`first=${state.preview.fullscreen}, second=${stateAfterFullOff.preview.fullscreen}`,
fullOn && stateAfterFullOff.preview.fullscreen === false ? "PASS" : "FAIL",
);
await selectValue('[data-action="select-profile"]', "xyzbc-trt");
await sleep(2000);
state = await getState();
await recordResult(
"profile-switch",
"Profile 切换到 xyzbc-trt",
"切换后 machineProfile=xyzbc-trtINI 重新加载",
`machineProfile=${state.machineProfile}, iniLoaded=${state.iniConfigReadiness.loaded}, iniPath=${state.iniConfigReadiness.path}`,
state.machineProfile === "xyzbc-trt" && state.iniConfigReadiness.loaded ? "PASS" : "FAIL",
);
await capture("02-profile-xyzbc");
await selectValue('[data-action="select-profile"]', "xyzac-trt");
await sleep(2000);
state = await getState();
await recordResult(
"profile-switch-back",
"Profile 切回 xyzac-trt",
"切回后 machineProfile=xyzac-trt",
`machineProfile=${state.machineProfile}, iniLoaded=${state.iniConfigReadiness.loaded}`,
state.machineProfile === "xyzac-trt" && state.iniConfigReadiness.loaded ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="stage-linuxcnc-sources"]', 5000);
let summary = await getSummary();
const stagedCount = summary.state.machineFileStaging?.gcodeSources?.length || 0;
const stagingStatus = classifyWarn(
stagedCount > 0 && summary.state.machineFileStaging.status === "staged",
`已 staged ${stagedCount} 个 LinuxCNC 五轴 G-code 源文件`,
`staging 未完成status=${summary.state.machineFileStaging.status}, lastError=${summary.state.machineFileStaging.lastError || "-"}`,
);
await recordResult(
"stage-linuxcnc-sources",
"Stage LinuxCNC 5-axis 源程序",
"点击 Stage 后应完成 machine files staging 并出现可选 G-code",
stagingStatus.text,
stagingStatus.status,
);
if (stagedCount > 0) {
const sourceRel = summary.state.machineFileStaging.gcodeSources[0].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";
await recordResult(
"load-vendored-program",
"加载 LinuxCNC 五轴源程序",
"选择源程序后 programSource=linuxcnc-vendored-5axis-gcode",
`programSource=${summary.state.programSource}, activeProgram=${summary.state.activeProgram}, sourceRel=${summary.state.programSourceRel || "-"}`,
loadedVendored ? "PASS" : "FAIL",
);
await capture("03-vendored-program-loaded");
} else {
await recordResult(
"load-vendored-program",
"加载 LinuxCNC 五轴源程序",
"应可加载 staged 后的 vendored 程序",
"因 staging 未完成,本项无法继续",
"FAIL",
);
}
state = await getState();
if (!state.machine.powerOn) {
await clickAndWait('[data-action="power"]');
await waitForState((nextState) => nextState.machine.powerOn === true, 8000, "machine power on before run").catch(() => null);
}
if (!state.machine.allHomed) {
await clickAndWait('[data-action="mode-jog"]');
await clickAndWait('[data-action="HOME"]');
await waitForState((nextState) => nextState.machine.allHomed === true, 8000, "machine homed before run").catch(() => null);
}
await clickAndWait('[data-action="mode-auto"]');
await clickAndWait('[data-action="RUN"]', 2500);
state = await getState();
await recordResult(
"run-program",
"Run 程序",
"点击 Run 后程序进入 running/complete活动行或运行反馈推进",
`runState=${state.runState}, activeLine=${state.activeLine}, source=${state.programExecutionSourceMode}, feedback=${state.programRuntimeFeedback?.apiName || "-"}`,
["running", "complete"].includes(state.runState) || Number(state.activeLine) !== 501 ? "PASS" : "FAIL",
);
await capture("04-run-state");
await clickAndWait('[data-action="PAUSE"]', 1200);
state = await getState();
await recordResult(
"pause-program",
"Pause 程序",
"点击 Pause 后 runState=paused",
`runState=${state.runState}, interpState=${state.machine.interpState}`,
state.runState === "paused" ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="RESUME"]', 1200);
state = await getState();
await recordResult(
"resume-program",
"Resume 程序",
"点击 Resume 后 runState 返回 running/idle",
`runState=${state.runState}, interpState=${state.machine.interpState}`,
["running", "idle", "complete"].includes(state.runState) ? "PASS" : "FAIL",
);
const beforeStepLine = state.activeLine;
await clickAndWait('[data-action="STEP"]', 1500);
state = await getState();
await recordResult(
"step-program",
"Step 单步执行",
"点击 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);
state = await getState();
await recordResult(
"stop-program",
"Stop 停止程序",
"点击 Stop 后 runState=stopped",
`runState=${state.runState}, interpState=${state.machine.interpState}`,
state.runState === "stopped" ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="RELOAD"]', 1200);
state = await getState();
await recordResult(
"reload-program",
"Reload 程序",
"点击 Reload 后 runState=idle程序回到起始状态",
`runState=${state.runState}, activeLine=${state.activeLine}`,
state.runState === "idle" ? "PASS" : "FAIL",
);
const fileInput = await page.$('[data-action="OPEN_FILE"]');
await fileInput.uploadFile(localProgramPath);
await sleep(3000);
state = await getState();
await recordResult(
"open-local-program",
"Open 本地 G-code 文件",
"上传本地文件后 activeProgram 为上传文件programSource=upload",
`activeProgram=${state.activeProgram}, programSource=${state.programSource}, lineCount=${state.lineCount}`,
state.activeProgram.endsWith("test-program.ngc") && ["upload", "operator-file"].includes(state.programSource) ? "PASS" : "FAIL",
);
await capture("05-local-program-opened");
await clickAndWait('[data-action="SAVE_SESSION"]', 2500);
state = await getState();
const saveSessionStatus = classifyWarn(
state.sessionPersistence.status === "saved",
`会话已保存:${state.sessionPersistence.path} (${state.sessionPersistence.storageMode})`,
`会话保存未成功status=${state.sessionPersistence.status}, error=${state.sessionPersistence.lastError || "-"}`,
);
await recordResult(
"save-session",
"Save Session",
"点击 Save Session 后会话状态应为 saved",
saveSessionStatus.text,
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();
await recordResult(
"restore-session",
"Restore Session",
"点击 Restore Session 后会话恢复到最近保存快照",
`modifiedX=${modifiedState.axisPose.x}, restoredX=${state.axisPose.x}, status=${state.sessionPersistence.status}`,
state.sessionPersistence.status === "restored" && state.axisPose.x !== modifiedState.axisPose.x ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="AUDIT_FULL_BOUNDARY"]', 6000);
summary = await getSummary();
const auditStatus = classifyWarn(
!summary.state.interpreterExecutionPending,
`Audit 执行完成fullBoundary=${summary.state.fullExecutionBoundary?.fullLinuxCncProgramExecutionReady}`,
"Audit 触发后仍在 pending 或未返回结果",
);
await recordResult(
"audit-full-boundary",
"Audit Full Boundary",
"点击 Audit 后应触发五轴 machine-file 运行审计并刷新边界状态",
`${auditStatus.text}; boundaryStatus=${summary.state.fullExecutionBoundary?.boundaryStatus || "-"}; machineRun=${summary.state.machineFileExecution?.summary?.machineFileExecutionReady ?? "-"}`,
auditStatus.status,
);
await capture("06-after-audit");
await clickAndWait('[data-action="estop"]');
state = await getState();
await recordResult(
"estop",
"E-STOP 急停",
"点击 E-STOP 后 estopActive=truerunState=estopped",
`estopActive=${state.machine.estopActive}, runState=${state.runState}, powerOn=${state.machine.powerOn}`,
state.machine.estopActive && state.runState === "estopped" ? "PASS" : "FAIL",
);
await clickAndWait('[data-action="reset"]');
state = await getState();
await recordResult(
"reset",
"RESET 复位",
"点击 RESET 后 estopActive=falsepowerOn=falsetaskState=estop-reset",
`estopActive=${state.machine.estopActive}, powerOn=${state.machine.powerOn}, taskState=${state.machine.taskState}`,
!state.machine.estopActive && !state.machine.powerOn && state.machine.taskState === "estop-reset" ? "PASS" : "FAIL",
);
const finalSummary = await getSummary();
await capture("07-final");
const screenshotAnalysis = await analyzeScreenshot(screenshots["01-home"]);
const report = {
generatedAt: new Date().toISOString(),
targetUrl,
chromePath: CHROME_PATH,
screenshots,
screenshotAnalysis,
finalSummary,
findings,
consoleLogs,
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().catch(() => {});
if (server) await new Promise((resolve) => server.close(resolve));
}
async function analyzeScreenshot(filePath) {
if (!filePath) return null;
const buffer = await fs.readFile(filePath);
const png = PNG.sync.read(buffer);
const { width, height, data } = png;
let sum = 0;
let nonBlackPixels = 0;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const luminance = (r + g + b) / 3;
sum += luminance;
if (luminance > 8) nonBlackPixels += 1;
}
return {
width,
height,
averageLuminance: Number((sum / (width * height)).toFixed(2)),
nonBlackRatio: Number((nonBlackPixels / (width * height)).toFixed(4)),
};
}