911 lines
33 KiB
JavaScript
911 lines
33 KiB
JavaScript
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=true,taskState=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=true,runState=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-trt,INI 重新加载",
|
||
`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=true,runState=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=false,powerOn=false,taskState=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)),
|
||
};
|
||
}
|