Add RTCP simulation QA updates

This commit is contained in:
2026-06-22 09:30:27 -04:00
parent 61e2fe8441
commit ea8e10031b
51 changed files with 10008 additions and 178 deletions

View File

@@ -0,0 +1,752 @@
import fs from "node:fs/promises";
import path from "node:path";
import puppeteer from "puppeteer-core";
import { PNG } from "pngjs";
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 localProgramPath = path.join(ROOT, "fixtures", "test-program.ngc");
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
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 = [];
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(URL, { 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 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.click(selector);
await sleep(waitMs);
await waitForAppIdle();
}
async function setInputValue(selector, value) {
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);
}
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 initialTaskHalStatus = classifyWarn(
!/pending|blocked/i.test(initial.taskHal),
`Task/HAL 已就绪:${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);
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;
await clickAndWait('[data-action="JOG_X_POS"]');
state = await getState();
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;
await clickAndWait('[data-action="JOG_Y_NEG"]');
state = await getState();
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-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",
);
await setInputValue('[data-action="mdi-command"]', "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 page.select('[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 page.select('[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 page.select('[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 后 runState=steppingactiveLine 前进或保持受控",
`runState=${state.runState}, activeLine=${state.activeLine}`,
state.runState === "stepping" || state.activeLine !== 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="JOG_X_POS"]');
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: URL,
chromePath: CHROME_PATH,
screenshots,
screenshotAnalysis,
finalSummary,
findings,
consoleLogs,
pageErrors,
requestFailures,
};
await fs.writeFile(path.join(OUTPUT_DIR, "site-test-report.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
} finally {
await browser.close();
}
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)),
};
}