357 lines
15 KiB
JavaScript
357 lines
15 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 QA_ROOT = path.resolve("/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test");
|
||
const OUTPUT_DIR = path.join(QA_ROOT, "output");
|
||
const SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "toolpath-preview-cases");
|
||
const CHROME_PATH = process.env.CHROME_PATH || "/usr/bin/google-chrome";
|
||
const FIXTURE_URL = "/web-rtcp-5axis-sim-plan/app/index.html";
|
||
|
||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||
await fs.mkdir(SCREENSHOT_DIR, { recursive: true });
|
||
|
||
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",
|
||
".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));
|
||
}
|
||
});
|
||
}
|
||
|
||
const server = createStaticServer(REPO_ROOT);
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
const address = server.address();
|
||
if (!address || typeof address === "string") {
|
||
throw new Error("failed to start static server");
|
||
}
|
||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||
|
||
const browser = await puppeteer.launch({
|
||
headless: true,
|
||
executablePath: CHROME_PATH,
|
||
defaultViewport: { width: 1600, height: 1200, deviceScaleFactor: 1 },
|
||
args: [
|
||
"--disable-gpu",
|
||
"--enable-webgl",
|
||
"--use-angle=swiftshader",
|
||
"--enable-unsafe-swiftshader",
|
||
"--no-sandbox",
|
||
],
|
||
});
|
||
|
||
const page = await browser.newPage();
|
||
const consoleErrors = [];
|
||
page.on("console", (msg) => {
|
||
if (msg.type() === "error") {
|
||
consoleErrors.push(msg.text());
|
||
}
|
||
});
|
||
page.on("pageerror", (error) => {
|
||
consoleErrors.push(error.message);
|
||
});
|
||
|
||
const report = {
|
||
generatedAt: new Date().toISOString(),
|
||
targetUrl: `${baseUrl}${FIXTURE_URL}`,
|
||
projectPath: path.join(REPO_ROOT, "web-rtcp-5axis-sim-plan", "app", "index.html"),
|
||
chromePath: CHROME_PATH,
|
||
screenshots: {},
|
||
cases: [],
|
||
consoleErrors,
|
||
};
|
||
|
||
async function wait(ms) {
|
||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function waitForState(predicate, timeoutMs = 20000, label = "state condition") {
|
||
const start = Date.now();
|
||
while (Date.now() - start < timeoutMs) {
|
||
const snapshot = await page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState())));
|
||
if (predicate(snapshot)) return snapshot;
|
||
await wait(100);
|
||
}
|
||
throw new Error(`timeout waiting for ${label}`);
|
||
}
|
||
|
||
async function getCanvasDataset() {
|
||
return page.$eval("[data-five-axis-canvas]", (canvas) => ({ ...canvas.dataset }));
|
||
}
|
||
|
||
async function waitForCanvasReady(timeoutMs = 20000) {
|
||
await page.waitForFunction(() => {
|
||
const canvas = document.querySelector("[data-five-axis-canvas]");
|
||
return canvas?.dataset?.threeReady === "true"
|
||
&& canvas?.dataset?.threePreviewScope === "machine-reference-and-toolpath";
|
||
}, { timeout: timeoutMs });
|
||
}
|
||
|
||
async function captureCase(name) {
|
||
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`);
|
||
const canvas = await page.$("[data-five-axis-canvas]");
|
||
if (!canvas) {
|
||
throw new Error(`missing canvas for ${name}`);
|
||
}
|
||
await canvas.screenshot({ path: screenshotPath });
|
||
report.screenshots[name] = screenshotPath;
|
||
return screenshotPath;
|
||
}
|
||
|
||
async function recordCase(name, summary, checks = []) {
|
||
const dataset = await getCanvasDataset();
|
||
const state = await page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState())));
|
||
const screenshotPath = report.screenshots[name];
|
||
const pixelStats = screenshotPath ? await analyzePng(screenshotPath) : null;
|
||
report.cases.push({
|
||
name,
|
||
summary,
|
||
status: checks.every((check) => check.pass) ? "PASS" : "FAIL",
|
||
checks,
|
||
pixelStats,
|
||
dataset,
|
||
state: {
|
||
activeProgram: state.activeProgram,
|
||
activeLine: state.activeLine,
|
||
programSource: state.programSource,
|
||
programExecutionSourceMode: state.programExecutionSourceMode,
|
||
programExecutionSummary: state.programExecution?.summary || null,
|
||
runState: state.runState,
|
||
rtcpState: state.rtcpState,
|
||
kinsType: state.kinsType,
|
||
activeLine: state.activeLine,
|
||
programExecutionMotionIndex: state.programExecutionMotionIndex,
|
||
programExecutionSampleIndex: state.programExecutionSampleIndex,
|
||
programRuntimeFeedbackSource: state.programRuntimeFeedback?.sourceMode || null,
|
||
},
|
||
});
|
||
}
|
||
|
||
function previewChecks(dataset, {
|
||
requirePathPoints = true,
|
||
requireExecutedPath = false,
|
||
requireArcPoints = false,
|
||
requireRapidFeed = false,
|
||
expectPathPoints = null,
|
||
expectRtcp = null,
|
||
} = {}) {
|
||
const checks = [
|
||
check("canvas ready", dataset.threeReady === "true", `threeReady=${dataset.threeReady}`),
|
||
check("WebGL renderer", dataset.threeRenderer === "webgl", `renderer=${dataset.threeRenderer}`),
|
||
check("机床参考模型", dataset.threePreviewScope === "machine-reference-and-toolpath" && dataset.threeMachineReferenceModel === "webgl-five-axis-reference", `scope=${dataset.threePreviewScope}, model=${dataset.threeMachineReferenceModel}`),
|
||
check("TCP 球标记", dataset.threeTcpMarker === "sphere" && dataset.threeToolExecutionMarker === "true", `tcp=${dataset.threeTcpMarker}, marker=${dataset.threeToolExecutionMarker}`),
|
||
check("刀轴线", dataset.threeToolAxisMarker === "line", `toolAxisMarker=${dataset.threeToolAxisMarker}`),
|
||
check("场景对象数量", Number(dataset.threeSceneObjects || 0) >= 12, `sceneObjects=${dataset.threeSceneObjects}`),
|
||
check("无 G-code 语义生成", dataset.threeNoGcodeSemanticsGeneration === "ok", `semanticGuard=${dataset.threeNoGcodeSemanticsGeneration}`),
|
||
];
|
||
if (requirePathPoints) {
|
||
checks.push(check("刀路预览点", Number(dataset.threePathPoints || 0) >= 1, `pathPoints=${dataset.threePathPoints}`));
|
||
}
|
||
if (requireExecutedPath) {
|
||
checks.push(check("执行轨迹点", Number(dataset.threeExecutedPathPoints || 0) >= 1, `executed=${dataset.threeExecutedPathPoints}`));
|
||
}
|
||
if (requireArcPoints) {
|
||
checks.push(check("圆弧轨迹点", Number(dataset.threeArcPathPoints || 0) >= 1, `arc=${dataset.threeArcPathPoints}`));
|
||
}
|
||
if (requireRapidFeed) {
|
||
checks.push(check("rapid/feed 区分", Number(dataset.threeRapidPathPoints || 0) >= 1 && Number(dataset.threeFeedPathPoints || 0) >= 1, `rapid=${dataset.threeRapidPathPoints}, feed=${dataset.threeFeedPathPoints}`));
|
||
}
|
||
if (expectPathPoints !== null) {
|
||
checks.push(check("路径点期望", Number(dataset.threePathPoints || 0) === expectPathPoints, `pathPoints=${dataset.threePathPoints}, expected=${expectPathPoints}`));
|
||
}
|
||
if (expectRtcp !== null) {
|
||
checks.push(check("RTCP 状态", dataset.threeRtcpState === expectRtcp, `rtcp=${dataset.threeRtcpState}`));
|
||
}
|
||
return checks;
|
||
}
|
||
|
||
function check(name, pass, detail) {
|
||
return { name, pass: Boolean(pass), detail };
|
||
}
|
||
|
||
function assertChecks(caseName, checks) {
|
||
const failed = checks.filter((item) => !item.pass);
|
||
if (failed.length > 0) {
|
||
console.warn(`${caseName} failed: ${failed.map((item) => `${item.name} (${item.detail})`).join("; ")}`);
|
||
}
|
||
}
|
||
|
||
try {
|
||
await page.goto(`${baseUrl}${FIXTURE_URL}`, { waitUntil: "networkidle2", timeout: 60000 });
|
||
await page.waitForSelector('[data-shell="gmoccapy-5axis"]', { timeout: 15000 });
|
||
await page.waitForFunction(() => Boolean(window.webRtcp5AxisSimulation?.getState), { timeout: 15000 });
|
||
await waitForState((state) => state.kinematicsRuntimeReadiness?.loaded === true, 20000, "kinematics runtime ready");
|
||
await waitForCanvasReady();
|
||
await wait(1200);
|
||
|
||
let dataset = await getCanvasDataset();
|
||
let checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true });
|
||
assertChecks("01-home-toolpath", checks);
|
||
await captureCase("01-home-toolpath");
|
||
await recordCase("01-home-toolpath", "默认首屏预览:机床参考模型、TCP 球、刀轴线、fixture 路径和执行轨迹可见", checks);
|
||
|
||
await page.evaluate(() => {
|
||
window.webRtcp5AxisSimulation.dispatch({
|
||
type: "LOAD_PROGRAM",
|
||
filename: "operator-demo.ngc",
|
||
content: [
|
||
"G90 G17",
|
||
"G0 X0 Y0 Z0",
|
||
"G1 X10 F100",
|
||
"G1 Y10",
|
||
"G1 X0",
|
||
"G1 Y0",
|
||
"G0 Z5",
|
||
"M5",
|
||
"M2",
|
||
].join("\n"),
|
||
});
|
||
});
|
||
await waitForState((state) => state.programExecutionSourceMode === "linuxcnc-interpreter-wasm" && state.activeProgram === "operator-demo.ngc", 20000, "operator demo loaded");
|
||
await waitForCanvasReady();
|
||
await wait(500);
|
||
dataset = await getCanvasDataset();
|
||
checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireRapidFeed: true });
|
||
assertChecks("02-operator-demo-toolpath", checks);
|
||
await captureCase("02-operator-demo-toolpath");
|
||
await recordCase("02-operator-demo-toolpath", "本地矩形 G-code:验证 LinuxCNC interpreter canonical motion、rapid/feed 区分、执行轨迹和 TCP 标记", checks);
|
||
|
||
await page.evaluate(() => {
|
||
window.webRtcp5AxisSimulation.dispatch({
|
||
type: "LOAD_PROGRAM",
|
||
filename: "operator-arc-demo.ngc",
|
||
content: [
|
||
"G90 G17",
|
||
"G0 X1 Y0 Z0",
|
||
"G2 X0 Y1 I-1 J0 F60",
|
||
"M2",
|
||
].join("\n"),
|
||
});
|
||
});
|
||
await waitForState((state) => state.activeProgram === "operator-arc-demo.ngc" && state.programExecution?.summary?.motionTypes?.includes("ARC_FEED"), 20000, "arc demo loaded");
|
||
await waitForCanvasReady();
|
||
await wait(500);
|
||
dataset = await getCanvasDataset();
|
||
checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireArcPoints: true });
|
||
assertChecks("03-arc-demo-toolpath", checks);
|
||
await captureCase("03-arc-demo-toolpath");
|
||
await recordCase("03-arc-demo-toolpath", "圆弧 G-code:验证 ARC_FEED 进入弧线轨迹图层并保留执行轨迹", checks);
|
||
|
||
await page.evaluate(() => document.querySelector('[data-action="clear-preview"]').click());
|
||
await waitForCanvasReady();
|
||
await wait(500);
|
||
dataset = await getCanvasDataset();
|
||
checks = previewChecks(dataset, { requirePathPoints: false, expectPathPoints: 0 });
|
||
assertChecks("04-clear-preview-reference", checks);
|
||
await captureCase("04-clear-preview-reference");
|
||
await recordCase("04-clear-preview-reference", "清空刀路后:路径点为 0,但机床参考模型、TCP 球和刀轴线仍应可见", checks);
|
||
|
||
await waitForState((state) => state.machineFileStaging?.status === "staged" && (state.machineFileStaging?.gcodeSources?.length || 0) >= 4, 25000, "machine files staged");
|
||
await page.select('[data-action="select-linuxcnc-gcode-source"]', "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc");
|
||
await waitForState((state) => state.activeProgram?.endsWith("impeller-7bl-xyzac.ngc"), 25000, "vendored impeller loaded");
|
||
await waitForCanvasReady();
|
||
await wait(1200);
|
||
dataset = await getCanvasDataset();
|
||
checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireRapidFeed: true, expectRtcp: "on" });
|
||
assertChecks("05-vendored-impeller-toolpath", checks);
|
||
await captureCase("05-vendored-impeller-toolpath");
|
||
await recordCase("05-vendored-impeller-toolpath", "LinuxCNC vendored 五轴 impeller 程序:验证长路径、switchkins/RTCP 状态、rapid/feed 图层和 TCP 执行轨迹", checks);
|
||
|
||
await page.evaluate(() => document.querySelector('[data-action="power"]').click());
|
||
await waitForState((state) => state.machine.taskState === "on", 10000, "machine powered on");
|
||
await page.evaluate(() => document.querySelector('[data-action="mode-manual"]').click());
|
||
await waitForState((state) => state.machine.mode === "manual", 10000, "manual mode selected");
|
||
await page.evaluate(() => document.querySelector('[data-action="HOME"]').click());
|
||
await waitForState((state) => state.machine.allHomed === true, 10000, "machine homed");
|
||
await page.evaluate(() => document.querySelector('[data-action="mode-auto"]').click());
|
||
await waitForState((state) => state.machine.mode === "auto", 10000, "auto mode selected");
|
||
await page.evaluate(() => document.querySelector('[data-action="kins-tcp"]').click());
|
||
await waitForState((state) => state.rtcpState === "on" && state.kinsType === "tcp-xyzac", 10000, "RTCP enabled");
|
||
await page.evaluate(() => document.querySelector('[data-action="RUN"]').click());
|
||
await waitForState((state) => state.runState === "running" && state.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm", 15000, "program running");
|
||
await waitForCanvasReady();
|
||
await wait(600);
|
||
dataset = await getCanvasDataset();
|
||
checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireRapidFeed: true, expectRtcp: "on" });
|
||
assertChecks("06-running-rtcp-toolpath", checks);
|
||
await captureCase("06-running-rtcp-toolpath");
|
||
await recordCase("06-running-rtcp-toolpath", "G-code 运行态:验证 task/HAL runtime feedback 驱动执行轨迹、当前段高亮、TCP 球和刀轴线跟随", checks);
|
||
|
||
report.consoleErrors = consoleErrors;
|
||
await fs.writeFile(
|
||
path.join(OUTPUT_DIR, "toolpath-preview-cases.json"),
|
||
`${JSON.stringify(report, null, 2)}\n`,
|
||
"utf8",
|
||
);
|
||
} finally {
|
||
await page.close().catch(() => {});
|
||
await browser.close().catch(() => {});
|
||
await new Promise((resolve) => server.close(resolve));
|
||
}
|
||
|
||
async function analyzePng(filePath) {
|
||
const buffer = await fs.readFile(filePath);
|
||
const png = PNG.sync.read(buffer);
|
||
const { width, height, data } = png;
|
||
let luminanceSum = 0;
|
||
let nonBlack = 0;
|
||
for (let index = 0; index < data.length; index += 4) {
|
||
const luminance = data[index] * 0.2126 + data[index + 1] * 0.7152 + data[index + 2] * 0.0722;
|
||
luminanceSum += luminance;
|
||
if (luminance > 8) nonBlack += 1;
|
||
}
|
||
const total = width * height;
|
||
return {
|
||
width,
|
||
height,
|
||
averageLuminance: Number((luminanceSum / total).toFixed(2)),
|
||
nonBlackRatio: Number((nonBlack / total).toFixed(4)),
|
||
};
|
||
}
|