Files
cnc_wams/qa/web-rtcp-5axis-site-test/capture-working7-full-functional-evidence.mjs

1115 lines
57 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 { randomUUID } from "node:crypto";
import puppeteer from "puppeteer-core";
import { PNG } from "pngjs";
const REPO_ROOT = path.resolve("/home/meswork/cnc_wams");
const QA_ROOT = path.join(REPO_ROOT, "qa/web-rtcp-5axis-site-test");
const OUTPUT_DIR = path.join(QA_ROOT, "output");
const EVIDENCE_SCOPE = "working7-full-functional-evidence";
const SCREENSHOT_DIR = path.join(OUTPUT_DIR, EVIDENCE_SCOPE);
const CHROME_PATH = process.env.CHROME_PATH || process.env.CHROMIUM || "/usr/bin/google-chrome";
const TARGET_URL = process.env.TARGET_URL || "";
const APP_URL = process.env.APP_URL || "/web-rtcp-5axis-sim-plan/app/index.html";
const JOB_ID = process.env.JOB_ID || `w7full-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`;
const REPORT_ID = process.env.REPORT_ID || `report-${JOB_ID}`;
const REPORT_BASENAME = `${EVIDENCE_SCOPE}-report`;
const WAIT_FAST = 50;
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.rm(SCREENSHOT_DIR, { recursive: true, force: true });
await fs.mkdir(SCREENSHOT_DIR, { recursive: true });
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 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: 1150, deviceScaleFactor: 1 },
ignoreHTTPSErrors: true,
args: [
"--ignore-certificate-errors",
"--disable-gpu",
"--enable-webgl",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--no-sandbox",
],
});
const page = await browser.newPage();
const consoleErrors = [];
const pageErrors = [];
const networkErrors = [];
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
page.on("response", (response) => {
if (response.status() >= 400) {
networkErrors.push(`${response.status()} ${response.url()}`);
}
});
const report = {
jobId: JOB_ID,
reportId: REPORT_ID,
evidenceScope: EVIDENCE_SCOPE,
generatedAt: new Date().toISOString(),
targetUrl,
chromePath: CHROME_PATH,
screenshotsDir: SCREENSHOT_DIR,
steps: [],
buttonInventory: [],
actionResults: [],
checks: [],
consoleErrors,
pageErrors,
networkErrors,
};
try {
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(() => document.querySelector("[data-five-axis-canvas]")?.dataset?.threeReady === "true", { timeout: 25000 });
await windowReady();
report.buttonInventory.push(...await collectButtonInventory());
await captureStep("01-loaded-inventory", "我打开主界面并记录按钮库存", "我确认主界面九大区域、所有 data-action 控件、canvas 和运行时 API 已经加载。");
await verifyInitialBlockedGates();
await verifySidebarAndModeButtons();
await verifyProfileAndPreviewButtons();
await verifyProgramSourceOpenReloadAndRunControls();
await verifyManualJogMdiOverridesSpindleCoolantAndSession();
await verifyDiagnosticsAndNativeHalEntryPoints();
addFinalChecks();
report.status = report.checks.every((check) => check.pass) &&
report.actionResults.every((action) => action.status === "PASS") &&
pageErrors.length === 0
? "PASS"
: "FAIL";
const jsonPath = path.join(OUTPUT_DIR, `${REPORT_BASENAME}.json`);
const pdfPath = path.join(OUTPUT_DIR, `${REPORT_BASENAME}.pdf`);
const markdownPath = path.join(OUTPUT_DIR, `${REPORT_BASENAME}.md`);
report.jsonPath = jsonPath;
report.pdfPath = pdfPath;
report.markdownPath = markdownPath;
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
await writeMarkdownReport(markdownPath, report);
await writePdfReport(pdfPath, report);
console.log(`working7_full_functional_status=${report.status}`);
console.log(`working7_full_functional_job_id=${report.jobId}`);
console.log(`working7_full_functional_report_id=${report.reportId}`);
console.log(`working7_full_functional_json=${jsonPath}`);
console.log(`working7_full_functional_pdf=${pdfPath}`);
console.log(`working7_full_functional_md=${markdownPath}`);
console.log(`working7_full_functional_screenshots=${SCREENSHOT_DIR}`);
if (report.status !== "PASS") process.exitCode = 1;
} finally {
await page.close().catch(() => {});
await browser.close().catch(() => {});
if (server) await new Promise((resolve) => server.close(resolve));
}
async function verifyInitialBlockedGates() {
await recordClick("RUN", "未上电直接 RUN 应被门禁拦截", async () => {
const before = await getState();
await click("RUN");
await waitForState((state) => /machine must be on|blocked/i.test(state.operatorMessage || ""), 5000, "RUN blocked while power off");
const after = await getState();
return check("RUN gate blocks when power is off", after.runState === before.runState && /machine must be on/i.test(after.operatorMessage), JSON.stringify({ before: summarizeState(before), after: summarizeState(after) }));
});
await captureStep("02-initial-run-blocked", "我验证未上电 RUN 门禁", "未上电时 RUN 不会进入执行状态,页面给出 machine must be on 门禁原因。");
await recordClick("mode-auto", "未上电 AUTO 应被门禁拦截", async () => {
const dom = await getDomEvidence();
return check("AUTO gate blocks when power is off", dom.buttons["mode-auto"]?.disabled === true && /machine must be on before AUTO/i.test(dom.buttons["mode-auto"]?.title || ""), JSON.stringify(dom.buttons["mode-auto"]));
});
await recordClick("mode-mdi", "未上电 MDI 应被门禁拦截", async () => {
const dom = await getDomEvidence();
return check("MDI gate blocks when power is off", dom.buttons["mode-mdi"]?.disabled === true && /machine must be on before MDI/i.test(dom.buttons["mode-mdi"]?.title || ""), JSON.stringify(dom.buttons["mode-mdi"]));
});
await recordClick("spindle-forward", "未上电主轴正转应被门禁拦截", async () => {
const state = await getState();
const dom = await getDomEvidence();
return check("Spindle gate blocks when power is off", dom.buttons["spindle-forward"]?.disabled === true && /spindle blocked: machine must be on/i.test(dom.buttons["spindle-forward"]?.title || "") && state.spindle.direction === "stop", JSON.stringify({ state: summarizeState(state), button: dom.buttons["spindle-forward"] }));
});
await recordClick("toggle-mist", "未上电冷却应被门禁拦截", async () => {
const state = await getState();
const dom = await getDomEvidence();
return check("Coolant gate blocks when power is off", dom.buttons["toggle-mist"]?.disabled === true && /coolant blocked: machine must be on/i.test(dom.buttons["toggle-mist"]?.title || "") && state.coolant.mist === false, JSON.stringify({ state: summarizeState(state), button: dom.buttons["toggle-mist"] }));
});
}
async function verifySidebarAndModeButtons() {
await recordClick("power", "POWER 上电", async () => {
await click("power");
const state = await waitForState((nextState) => nextState.machine.powerOn === true && nextState.machine.taskState === "on", 10000, "power on");
return check("POWER sets task state ON", state.machine.powerOn === true && state.machine.taskState === "on", JSON.stringify(summarizeState(state)));
});
await recordClick("HOME", "HOME 回零", async () => {
await click("HOME");
const state = await waitForState((nextState) => nextState.machine.allHomed === true && nextState.machine.mode === "manual", 12000, "home");
return check("HOME sets allHomed and keeps MANUAL", state.machine.allHomed === true && state.machine.mode === "manual", JSON.stringify(summarizeState(state)));
});
await captureStep("03-powered-homed", "我完成上电和回零", "POWER 和 HOME 后,机床处于 on/manual/allHomed 状态DRO 与轴值保持可读。");
await recordClick("mode-auto", "AUTO 模式", async () => {
await click("mode-auto");
const state = await waitForState((nextState) => nextState.machine.mode === "auto" && nextState.machine.powerOn === true, 10000, "auto");
const dom = await getDomEvidence();
return check("AUTO active and MANUAL inactive", state.machine.mode === "auto" && dom.buttons["mode-auto"]?.active === "true" && dom.buttons["mode-manual"]?.active === "false", JSON.stringify({ state: summarizeState(state), buttons: dom.buttons }));
});
await captureStep("04-auto-right-panel", "我验证右侧 AUTO 按钮", "右侧 AUTO 变为 activeMANUAL 变为 inactivepower/home 状态未丢失。");
await recordClick("mode-manual", "MANUAL 模式", async () => {
await click("mode-manual");
const state = await waitForState((nextState) => nextState.machine.mode === "manual", 10000, "manual");
const dom = await getDomEvidence();
return check("MANUAL active and AUTO inactive", state.machine.mode === "manual" && dom.buttons["mode-manual"]?.active === "true" && dom.buttons["mode-auto"]?.active === "false", JSON.stringify({ state: summarizeState(state), buttons: dom.buttons }));
});
await recordClick("mode-jog", "JOG 入口归入 MANUAL 点动页", async () => {
await click("mode-jog");
const state = await waitForState((nextState) => nextState.machine.mode === "manual", 10000, "jog/manual");
const dom = await getDomEvidence();
return check("JOG button keeps manual jog page active", state.machine.mode === "manual" && dom.buttons["mode-jog"]?.active === "true", JSON.stringify({ state: summarizeState(state), buttons: dom.buttons["mode-jog"] }));
});
await recordClick("mode-mdi", "MDI 模式", async () => {
await click("mode-mdi");
const state = await waitForState((nextState) => nextState.machine.mode === "mdi", 10000, "mdi");
const dom = await getDomEvidence();
return check("MDI active", state.machine.mode === "mdi" && dom.buttons["mode-mdi"]?.active === "true", JSON.stringify({ state: summarizeState(state), buttons: dom.buttons["mode-mdi"] }));
});
await recordClick("mode-manual", "返回 MANUAL", async () => {
await click("mode-manual");
const state = await waitForState((nextState) => nextState.machine.mode === "manual", 10000, "manual");
return check("Back to MANUAL", state.machine.mode === "manual", JSON.stringify(summarizeState(state)));
});
await recordClick("kins-tcp", "右侧 TCP 打开 RTCP", async () => {
await click("kins-tcp");
const state = await waitForState((nextState) => nextState.kinsType.startsWith("tcp-") && nextState.rtcpState === "on", 10000, "tcp kins");
const canvas = await getCanvasDataset();
return check("TCP kinematics enables RTCP", canvas.threeRtcpState === "on" && state.rtcpState === "on", JSON.stringify({ state: summarizeState(state), canvas }));
});
await recordClick("kins-identity", "右侧 IDENTITY 关闭 RTCP", async () => {
await click("kins-identity");
const state = await waitForState((nextState) => nextState.kinsType === "identity" && nextState.rtcpState === "off", 10000, "identity kins");
const canvas = await getCanvasDataset();
return check("IDENTITY disables RTCP", canvas.threeRtcpState === "off" && state.rtcpState === "off", JSON.stringify({ state: summarizeState(state), canvas }));
});
await captureStep("05-right-sidebar-all-states", "我验证右侧全班按钮", "右侧 E-STOP/POWER/RESET/AUTO/MANUAL/JOG/MDI/IDENTITY/TCP 的状态和门禁均已实际点击验证。");
await recordClick("estop", "E-STOP 急停", async () => {
await click("estop");
const state = await waitForState((nextState) => nextState.machine.estopActive === true && nextState.machine.powerOn === false, 10000, "estop");
return check("E-STOP drops power/spindle/coolant", state.machine.estopActive && !state.machine.powerOn && !state.spindle.enabled && !state.coolant.flood && !state.coolant.mist, JSON.stringify(summarizeState(state)));
});
await recordClick("power", "急停后 POWER 被阻止", async () => {
await click("power");
const state = await waitForState((nextState) => /reset estop first/i.test(nextState.operatorMessage || ""), 5000, "power blocked by estop");
return check("POWER is blocked until RESET after E-STOP", state.machine.estopActive === true && state.machine.powerOn === false, JSON.stringify(summarizeState(state)));
});
await recordClick("reset", "RESET 清除急停", async () => {
await click("reset");
const state = await waitForState((nextState) => nextState.machine.estopActive === false && nextState.machine.powerOn === false, 10000, "reset");
return check("RESET clears estop and leaves power off", !state.machine.estopActive && !state.machine.powerOn, JSON.stringify(summarizeState(state)));
});
await recordClick("power", "RESET 后重新上电", async () => {
await click("power");
const state = await waitForState((nextState) => nextState.machine.powerOn === true, 10000, "power on after reset");
return check("POWER works after RESET", state.machine.powerOn === true, JSON.stringify(summarizeState(state)));
});
await recordClick("HOME", "RESET 后重新回零", async () => {
await click("HOME");
const state = await waitForState((nextState) => nextState.machine.allHomed === true, 10000, "home after reset");
return check("HOME works after RESET", state.machine.allHomed === true, JSON.stringify(summarizeState(state)));
});
}
async function verifyProfileAndPreviewButtons() {
await selectProfile("xyzbc-trt", "切换到 xyzbc-trt 轮廓");
await captureStep("06-profile-xyzbc", "我验证 Profile 下拉切换", "切换到 xyzbc-trt 后INI、运动学和机床文件 staging 会重新加载到对应 profile。");
await selectProfile("gmoccapy-xyzab", "切换到 reference-only gmoccapy-xyzab");
await recordClick("kins-tcp", "reference-only profile 的 TCP 禁用", async () => {
const domBefore = await getDomEvidence();
await click("kins-tcp");
await wait(WAIT_FAST);
const state = await getState();
const domAfter = await getDomEvidence();
return check("TCP stays disabled for reference-only profile", domBefore.buttons["kins-tcp"]?.disabled === true && domAfter.buttons["kins-tcp"]?.disabled === true && state.rtcpState === "off", JSON.stringify({ before: domBefore.buttons["kins-tcp"], after: domAfter.buttons["kins-tcp"], state: summarizeState(state) }));
});
await selectProfile("xyzac-trt", "切回 xyzac-trt 轮廓");
await waitForState((state) => state.iniConfigReadiness?.ready === true && state.machineFileStaging?.status === "staged", 20000, "xyzac restored");
for (const [action, view] of [["view-x", "x"], ["view-y", "y"], ["view-z", "z"]]) {
await recordClick(action, `预览 ${view.toUpperCase()} 视图`, async () => {
await click(action);
const state = await waitForState((nextState) => nextState.preview.selectedView === view, 5000, `${view} view`);
const canvas = await getCanvasDataset();
return check(`${view.toUpperCase()} view updates canvas dataset`, canvas.threeSelectedView === view && state.preview.selectedView === view, JSON.stringify({ state: summarizeState(state), canvas }));
});
}
await recordClick("reset-view", "预览 Fit/复位视图", async () => {
await click("reset-view");
const state = await waitForState((nextState) => nextState.preview.selectedView === "iso", 5000, "reset view");
const canvas = await getCanvasDataset();
return check("Reset view returns to iso", canvas.threeSelectedView === "iso" && state.preview.selectedView === "iso", JSON.stringify({ state: summarizeState(state), canvas }));
});
await recordClick("clear-preview", "清除刀路预览", async () => {
await click("clear-preview");
const state = await waitForState((nextState) => Number(nextState.preview.pathPoints) === 0, 5000, "clear preview");
const canvas = await getCanvasDataset();
return check("Clear preview removes path points", Number(canvas.threePathPoints || 0) === 0 && Number(state.preview.pathPoints) === 0, JSON.stringify({ state: summarizeState(state), canvas }));
});
await recordClick("RELOAD", "Reload 恢复预览路径", async () => {
await click("RELOAD");
const state = await waitForState((nextState) => Number(nextState.preview.pathPoints) > 0 && nextState.runState === "idle", 5000, "reload restores preview");
const canvas = await getCanvasDataset();
return check("Reload restores toolpath preview points", Number(canvas.threePathPoints || 0) > 0 && state.runState === "idle", JSON.stringify({ state: summarizeState(state), canvas }));
});
await captureStep("07-preview-buttons", "我验证刀具预览按钮和刀路状态", "X/Y/Z/Fit/Clear/Reload 后canvas dataset 中的 selectedView、pathPoints、RTCP 状态与 store 同步。");
}
async function verifyProgramSourceOpenReloadAndRunControls() {
await recordClick("stage-linuxcnc-sources", "Stage LinuxCNC source", async () => {
await click("stage-linuxcnc-sources");
const state = await waitForState((nextState) => nextState.machineFileStaging?.status === "staged" && nextState.machineFileStaging?.gcodeSources?.length > 0, 20000, "stage sources");
return check("Stage exposes LinuxCNC G-code sources", state.machineFileStaging.status === "staged" && state.machineFileStaging.gcodeSources.length > 0, JSON.stringify(summarizeState(state)));
});
await selectFirstLinuxCncSource();
await captureStep("08-linuxcnc-source-loaded", "我验证 LinuxCNC G-code 来源选择", "Stage 和 source 下拉选择后G-code 面板、程序行、刀路预览和 interpreter canonical motion 均已刷新。");
await uploadProgramViaOpen();
await captureStep("09-open-file-loaded", "我验证 Open 文件按钮", "通过隐藏 file input 实际上传本地 fixture/test-program.ngc页面加载为 operator-file 程序。");
await recordClick("RUN_READY", "Run Ready 一键准备", async () => {
await click("RUN_READY");
const state = await waitForState((nextState) => nextState.machine.powerOn === true && nextState.machine.allHomed === true && nextState.machine.mode === "auto" && nextState.machineFileStaging?.selectedGcodeSourceRel, 25000, "run ready");
return check("Run Ready powers on, homes, enters AUTO, and opens a staged source", state.machine.powerOn && state.machine.allHomed && state.machine.mode === "auto" && Boolean(state.machineFileStaging.selectedGcodeSourceRel), JSON.stringify(summarizeState(state)));
});
await captureStep("10-run-ready", "我验证 Run Ready", "Run Ready 完成 power/home/auto/source/session 前置条件,准备进入 Task/HAL 程序执行。");
await recordClick("RUN", "RUN 执行程序", async () => {
await click("RUN");
const state = await waitForState((nextState) => nextState.runState === "running" && nextState.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm", 20000, "run");
await wait(350);
const later = await getState();
const canvas = await getCanvasDataset();
return check("RUN produces Task/HAL runtime feedback and executed path", later.runState === "running" && Number(canvas.threeExecutedPathPoints || 0) > 0 && later.machine.interpState === "reading", JSON.stringify({ state: summarizeState(later), canvas }));
});
await captureStep("11-run-realtime-path", "我验证刀具实时执行路径", "RUN 后 G-code 当前行、DRO、Task/HAL runtime feedback、canvas executedPathPoints 和当前段高亮同步变化。");
await recordClick("PAUSE", "PAUSE 暂停", async () => {
await click("PAUSE");
const state = await waitForState((nextState) => nextState.runState === "paused" && nextState.machine.taskPaused === true, 10000, "pause");
return check("PAUSE sets paused state", state.runState === "paused" && state.machine.taskPaused, JSON.stringify(summarizeState(state)));
});
await recordClick("RESUME", "RESUME 继续", async () => {
await click("RESUME");
const state = await waitForState((nextState) => nextState.runState === "running" && nextState.machine.interpState === "reading", 10000, "resume");
return check("RESUME returns to running", state.runState === "running" && state.machine.interpState === "reading", JSON.stringify(summarizeState(state)));
});
await recordClick("PAUSE", "STEP 前暂停", async () => {
await click("PAUSE");
const state = await waitForState((nextState) => nextState.runState === "paused", 10000, "pause before step");
return check("Paused before STEP", state.runState === "paused", JSON.stringify(summarizeState(state)));
});
await recordClick("STEP", "STEP 单步", async () => {
const before = await getState();
await click("STEP");
const state = await waitForState((nextState) => nextState.machine.taskPaused === true && nextState.taskHalStatus?.task?.singleStepping === true, 10000, "step");
return check("STEP performs one task/HAL step", state.taskHalStatus?.task?.singleStepping === true && Number(state.taskHalStatus?.ui?.taskCycle || 0) >= Number(before.taskHalStatus?.ui?.taskCycle || 0), JSON.stringify({ before: summarizeState(before), after: summarizeState(state) }));
});
await captureStep("12-pause-resume-step", "我验证暂停、继续和单步", "PAUSE/RESUME/STEP 的状态、interpState、taskPaused 和 Task/HAL singleStepping 均已验证。");
await recordClick("STOP", "STOP 停止", async () => {
await click("STOP");
const state = await waitForState((nextState) => nextState.runState === "stopped" && nextState.machine.interpState === "idle", 10000, "stop");
return check("STOP aborts motion and returns idle", state.runState === "stopped" && state.machine.interpState === "idle", JSON.stringify(summarizeState(state)));
});
await captureStep("13-stop-idle", "我验证 STOP 停止", "STOP 后 interpreter 返回 idle进给速度清零Task/HAL status loop 停止。");
}
async function verifyManualJogMdiOverridesSpindleCoolantAndSession() {
await recordClick("mode-manual", "切到 MANUAL 准备点动", async () => {
await click("mode-manual");
const state = await waitForState((nextState) => nextState.machine.mode === "manual", 10000, "manual before jog");
return check("Manual mode before jog", state.machine.mode === "manual", JSON.stringify(summarizeState(state)));
});
for (const [action, axis, direction] of [
["JOG_X_NEG", "x", -1],
["JOG_X_POS", "x", 1],
["JOG_Y_NEG", "y", -1],
["JOG_Y_POS", "y", 1],
]) {
await recordClick(action, `${action} 点动`, async () => {
const before = await getState();
await click(action);
const state = await waitForState((nextState) => direction > 0
? Number(nextState.axisPose?.[axis] || 0) > Number(before.axisPose?.[axis] || 0)
: Number(nextState.axisPose?.[axis] || 0) < Number(before.axisPose?.[axis] || 0), 10000, action);
return check(`${action} changes ${axis.toUpperCase()} axis`, true, JSON.stringify({ before: summarizeState(before), after: summarizeState(state) }));
});
}
await captureStep("14-manual-jog-axis-values", "我验证手动点动和机床轴值", "X-/X+/Y-/Y+ 均实际改变对应轴值DRO 和 axisPose 同步。");
await recordClick("mode-mdi", "切到 MDI", async () => {
await click("mode-mdi");
const state = await waitForState((nextState) => nextState.machine.mode === "mdi", 10000, "mdi before command");
return check("MDI mode active", state.machine.mode === "mdi", JSON.stringify(summarizeState(state)));
});
await recordAction("mdi-submit", "MDI 输入并执行 G90 X12.5 Y-4 Z1.25 F900", async () => {
await setMdiCommand("G90 X12.5 Y-4 Z1.25 F900");
await submitMdiCommand();
const state = await waitForState((nextState) => nextState.runState === "mdi" && Math.abs(Number(nextState.axisPose?.x || 0) - 12.5) < 1e-9, 10000, "mdi command");
return check("MDI updates axis pose and history", state.axisPose.x === 12.5 && state.axisPose.y === -4 && state.mdiHistory?.includes("G90 X12.5 Y-4 Z1.25 F900"), JSON.stringify(summarizeState(state)));
});
await recordClick("mdi-history", "MDI 历史快捷按钮", async () => {
const historyButtonExists = await page.$('[data-action="mdi-history"]');
if (!historyButtonExists) {
return check("MDI history button exists", false, "missing [data-action=mdi-history]");
}
await page.click('[data-action="mdi-history"]');
const state = await waitForState((nextState) => nextState.runState === "mdi" && nextState.activeProgram === "MDI", 8000, "mdi history");
return check("MDI history executes", state.activeProgram === "MDI" && state.programSource === "operator-mdi", JSON.stringify(summarizeState(state)));
});
await recordClick("MDI_RUN", "底部 MDI_RUN 执行当前 MDI", async () => {
await click("MDI_RUN");
const state = await waitForState((nextState) => nextState.runState === "mdi" && nextState.machine.mode === "mdi", 8000, "bottom mdi run");
return check("Bottom MDI_RUN executes in MDI mode", state.runState === "mdi" && state.machine.mode === "mdi", JSON.stringify(summarizeState(state)));
});
await captureStep("15-mdi-execution", "我验证 MDI 执行过程", "MDI 输入、表单提交、历史按钮和底部 MDI_RUN 都执行到 store/runtime并写入历史。");
await recordClick("mode-manual", "返回 MANUAL 准备倍率", async () => {
await click("mode-manual");
const state = await waitForState((nextState) => nextState.machine.mode === "manual", 10000, "manual before overrides");
return check("Manual before overrides", state.machine.mode === "manual", JSON.stringify(summarizeState(state)));
});
for (const [action, predicate, label] of [
["rapid-override-down", (state) => Number(state.feed.rapidOverride) === 90, "Rapid -"],
["rapid-override-up", (state) => Number(state.feed.rapidOverride) === 100, "Rapid +"],
["rapid-override-reset", (state) => Number(state.feed.rapidOverride) === 100, "Rapid 100"],
["feed-override-up", (state) => Number(state.feed.feedOverride) === 110, "Feed +"],
["feed-override-down", (state) => Number(state.feed.feedOverride) === 100, "Feed -"],
["feed-override-reset", (state) => Number(state.feed.feedOverride) === 100, "Feed 100"],
["spindle-override-up", (state) => Number(state.spindle.override) === 110, "Spindle +"],
["spindle-override-down", (state) => Number(state.spindle.override) === 100, "Spindle -"],
["spindle-override-reset", (state) => Number(state.spindle.override) === 100, "Spindle 100"],
]) {
await recordClick(action, label, async () => {
await click(action);
const state = await waitForState(predicate, 8000, label);
return check(`${label} updates override`, predicate(state), JSON.stringify(summarizeState(state)));
});
}
for (const [action, stateKey, label] of [
["ignore-limits", "ignoreLimits", "Limits"],
["block-delete", "optionalBlocks", "/ Block"],
["optional-stop", "optionalStop", "M1"],
]) {
await recordClick(action, label, async () => {
const before = await getState();
await click(action);
const state = await waitForState((nextState) => Boolean(nextState.gmoccapyGui?.[stateKey]) !== Boolean(before.gmoccapyGui?.[stateKey]), 8000, label);
return check(`${label} toggles HAL GUI state`, Boolean(state.gmoccapyGui?.[stateKey]) !== Boolean(before.gmoccapyGui?.[stateKey]), JSON.stringify({ before: summarizeState(before), after: summarizeState(state) }));
});
}
await captureStep("16-overrides-hal", "我验证倍率和 HAL 输入", "Rapid/Feed/Spindle 倍率和 Limits/Block/M1 均通过按钮实际改变状态,并在诊断中显示 last HAL effect。");
for (const [action, predicate, label] of [
["spindle-forward", (state) => state.spindle.enabled === true && state.spindle.direction === "forward", "主轴 FWD"],
["spindle-reverse", (state) => state.spindle.enabled === true && state.spindle.direction === "reverse", "主轴 REV"],
["spindle-stop", (state) => state.spindle.enabled === false && state.spindle.direction === "stop", "主轴 STOP"],
["toggle-flood", (state, before) => state.coolant.flood !== before.coolant.flood, "Flood"],
["toggle-mist", (state, before) => state.coolant.mist !== before.coolant.mist, "Mist"],
]) {
await recordClick(action, label, async () => {
const before = await getState();
await click(action);
const state = await waitForState((nextState) => predicate(nextState, before), 8000, label);
return check(`${label} updates state`, predicate(state, before), JSON.stringify({ before: summarizeState(before), after: summarizeState(state) }));
});
}
await captureStep("17-spindle-coolant", "我验证主轴和冷却", "FWD/REV/STOP、Flood、Mist 均受 power gate 控制并实际更新状态。");
await recordClick("SAVE_SESSION", "Save Session", async () => {
await click("SAVE_SESSION");
const state = await waitForState((nextState) => nextState.sessionPersistence?.status === "saved", 15000, "save session");
return check("Save Session persists snapshot", state.sessionPersistence.status === "saved" && Boolean(state.sessionPersistence.path), JSON.stringify(summarizeState(state)));
});
await selectProfile("xyzbc-trt", "会话恢复前临时切换 profile");
await recordClick("RESTORE_SESSION", "Restore Session", async () => {
await click("RESTORE_SESSION");
const state = await waitForState((nextState) => nextState.sessionPersistence?.status === "restored" && nextState.machineProfile === "xyzac-trt", 15000, "restore session");
return check("Restore Session restores saved profile", state.sessionPersistence.status === "restored" && state.machineProfile === "xyzac-trt", JSON.stringify(summarizeState(state)));
});
await recordClick("FULL", "Full 全屏状态切换", async () => {
const before = await getState();
await click("FULL");
const state = await waitForState((nextState) => Boolean(nextState.preview.fullscreen) !== Boolean(before.preview.fullscreen), 5000, "fullscreen");
return check("FULL toggles preview fullscreen flag", Boolean(state.preview.fullscreen) !== Boolean(before.preview.fullscreen), JSON.stringify({ before: summarizeState(before), after: summarizeState(state) }));
});
await captureStep("18-session-fullscreen", "我验证会话和 Full 按钮", "Save/Restore 通过 OPFS 或 fallback 存储恢复 profile/程序/UI 状态Full 切换 preview.fullscreen。");
}
async function verifyDiagnosticsAndNativeHalEntryPoints() {
await recordClick("AUDIT_FULL_BOUNDARY", "Audit Full Boundary", async () => {
await click("AUDIT_FULL_BOUNDARY");
const state = await waitForState((nextState) => nextState.machineFileExecution || /machine-file|LinuxCNC/i.test(nextState.operatorMessage || ""), 20000, "audit full boundary");
return check("Audit starts full LinuxCNC boundary path", Boolean(state.machineFileExecution || state.fullExecutionBoundary), JSON.stringify(summarizeState(state)));
});
await recordAction("GMOCAPY_HAL_PIN ignore-limits", "公开 API 写入 gmoccapy HAL pin", async () => {
await page.evaluate(() => window.webRtcp5AxisSimulation.dispatch({
type: "GMOCAPY_HAL_PIN",
pin: "gmoccapy.ignore-limits",
value: true,
}));
const state = await waitForState((nextState) => nextState.gmoccapyGui.ignoreLimits === true && /ignore-limits/i.test(nextState.operatorMessage || ""), 8000, "hal pin");
return check("Native HAL pin model updates Web state", state.gmoccapyGui.ignoreLimits === true, JSON.stringify(summarizeState(state)));
});
await recordAction("GMOCAPY_NATIVE_PAGE file", "公开 API 切换 gmoccapy native file page", async () => {
await page.evaluate(() => window.webRtcp5AxisSimulation.dispatch({
type: "GMOCAPY_NATIVE_PAGE",
page: "file-load",
}));
const state = await waitForState((nextState) => nextState.gmoccapyGui.activeNativePage === "file-load", 8000, "native page file");
return check("Native page model switches to file-load page", state.gmoccapyGui.activeNativePage === "file-load", JSON.stringify(summarizeState(state)));
});
await captureStep("19-diagnostics-audit", "我验证诊断和 gmoccapy 边界", "Task policy、INI、Task/HAL、Full boundary、gmoccapy comm/HAL/native page 诊断均可读HAL pin 模型可实际更新页面状态。");
}
async function selectProfile(profileId, label) {
await recordAction("select-profile", label, async () => {
await page.select('[data-action="select-profile"]', profileId);
const state = await waitForState((nextState) => nextState.machineProfile === profileId, 15000, `profile ${profileId}`);
if (profileId === "gmoccapy-xyzab") {
await waitForState((nextState) => nextState.machineProfile === profileId && nextState.rtcpState === "off", 10000, `profile ${profileId} reference profile ready`);
} else {
await waitForState((nextState) => nextState.machineProfile === profileId && nextState.kinematicsRuntimeReadiness?.loaded === true, 20000, `profile ${profileId} kins`);
}
return check(`Profile switched to ${profileId}`, state.machineProfile === profileId, JSON.stringify(summarizeState(await getState())));
});
}
async function selectFirstLinuxCncSource() {
await recordAction("select-linuxcnc-gcode-source", "选择第一个 LinuxCNC 5-axis G-code source", async () => {
const sourceRel = await page.$eval('[data-action="select-linuxcnc-gcode-source"]', (select) => {
const options = [...select.options].map((option) => option.value).filter(Boolean);
return options[0] || "";
});
if (!sourceRel) return check("LinuxCNC source option exists", false, "no source options");
await page.select('[data-action="select-linuxcnc-gcode-source"]', sourceRel);
const state = await waitForState((nextState) => nextState.machineFileStaging?.selectedGcodeSourceRel === sourceRel && nextState.programExecution?.summary?.motionEventCount > 0 && nextState.programExecutionSourceMode === "linuxcnc-interpreter-wasm", 20000, "source load");
const canvas = await getCanvasDataset();
return check("Selected LinuxCNC source has interpreter preview", state.programExecutionSourceMode === "linuxcnc-interpreter-wasm" && Number(canvas.threePathPoints || 0) > 0, JSON.stringify({ sourceRel, state: summarizeState(state), canvas }));
});
}
async function uploadProgramViaOpen() {
await recordAction("OPEN", "Open 上传本地 G-code fixture", async () => {
const filePath = path.join(QA_ROOT, "fixtures/test-program.ngc");
const input = await page.$('[data-action="OPEN_FILE"]');
if (!input) return check("Open file input exists", false, "missing OPEN_FILE");
await input.uploadFile(filePath);
const state = await waitForState((nextState) => nextState.programSource === "operator-file" && nextState.activeProgram === "test-program.ngc", 12000, "open file");
const canvas = await getCanvasDataset();
return check("OPEN loads local program and preview", state.programSource === "operator-file" && Number(canvas.threePathPoints || 0) > 0, JSON.stringify({ state: summarizeState(state), canvas }));
});
}
async function recordClick(action, label, executor) {
return recordAction(action, label, executor);
}
async function recordAction(action, label, executor) {
const before = await getState().catch(() => null);
const beforeDom = await getDomEvidence().catch(() => null);
let result;
let error = null;
try {
result = await executor();
} catch (caught) {
error = caught instanceof Error ? caught.stack || caught.message : String(caught);
result = check(label, false, error);
}
const after = await getState().catch(() => null);
const afterDom = await getDomEvidence().catch(() => null);
const checks = Array.isArray(result) ? result : [result];
const record = {
action,
label,
status: checks.every((item) => item?.pass) ? "PASS" : "FAIL",
checks,
error,
before: summarizeState(before || {}),
after: summarizeState(after || {}),
domBefore: summarizeDom(beforeDom),
domAfter: summarizeDom(afterDom),
};
report.actionResults.push(record);
return record;
}
async function captureStep(name, title, description) {
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
const [state, dom, pixelStats] = await Promise.all([
getState(),
getDomEvidence(),
analyzePng(screenshotPath),
]);
const step = {
name,
title,
description,
screenshotPath,
pixelStats,
state: summarizeState(state),
dom,
};
report.steps.push(step);
return step;
}
async function windowReady() {
await waitForState((state) => (
state.kinematicsRuntimeReadiness?.loaded === true &&
state.interpreterRuntimeReadiness?.loaded === true &&
state.taskHalRuntimeReadiness?.loaded === true &&
state.machineFileStaging?.status === "staged" &&
!state.interpreterExecutionPending
), 40000, "runtime, machine files, and interpreter preview ready");
}
async function click(action) {
await page.evaluate((selector) => {
const element = document.querySelector(selector);
if (!element) throw new Error(`missing action ${selector}`);
element.click();
}, `[data-action="${action}"]`);
}
async function setMdiCommand(command) {
await page.$eval('[data-action="mdi-command"]', (input, value) => {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}, command);
await waitForState((state) => state.machine.mdiCommand === command.toUpperCase(), 5000, "MDI input staged");
}
async function submitMdiCommand() {
await page.$eval('[data-action="mdi-form"]', (form) => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
}
async function getState() {
return page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState())));
}
async function getCanvasDataset() {
return page.$eval("[data-five-axis-canvas]", (canvas) => ({ ...canvas.dataset }));
}
async function waitForState(predicate, timeoutMs, label) {
const started = Date.now();
let lastState = null;
while (Date.now() - started < timeoutMs) {
lastState = await getState();
if (predicate(lastState)) return lastState;
await wait(WAIT_FAST);
}
throw new Error(`timeout waiting for ${label}: ${JSON.stringify(summarizeState(lastState || {}))}`);
}
async function getDomEvidence() {
return page.evaluate(() => {
const text = (selector) => document.querySelector(selector)?.textContent?.trim() || "";
const button = (action) => {
const element = document.querySelector(`[data-action="${action}"]`);
return element ? {
exists: true,
tagName: element.tagName,
disabled: Boolean(element.disabled),
active: element.dataset?.active || null,
commandReady: element.dataset?.commandReady || null,
ariaDisabled: element.getAttribute("aria-disabled"),
title: element.getAttribute("title") || "",
label: element.getAttribute("aria-label") || element.textContent?.trim() || "",
iconName: element.dataset?.iconName || null,
iconVariant: element.dataset?.iconVariant || null,
gmoccapyButtonId: element.dataset?.gmoccapyButtonId || null,
nativePanel: element.dataset?.gmoccapyNativePanel || null,
nativeButtonIndex: element.dataset?.gmoccapyNativeButtonIndex || null,
halPin: element.dataset?.gmoccapyHalPin || null,
} : { exists: false };
};
const actionNames = [...document.querySelectorAll("[data-action]")]
.map((element) => element.dataset.action)
.filter(Boolean);
const buttons = Object.fromEntries([...new Set(actionNames)].map((action) => [action, button(action)]));
return {
regions: window.webRtcp5AxisSimulation.getRegions?.() || null,
actionCount: actionNames.length,
uniqueActions: [...new Set(actionNames)].sort(),
buttons,
canvas: { ...(document.querySelector("[data-five-axis-canvas]")?.dataset || {}) },
gcode: {
currentLine: Number(document.querySelector("[data-active-program-line]")?.dataset.activeProgramLine || 0),
activeRowLine: Number(document.querySelector(".gcode-row.active")?.dataset.programLine || 0),
activeExecution: document.querySelector(".gcode-row.active [data-line-execution]")?.textContent?.trim() || "",
},
values: {
activeProgram: text(".gcode-header strong"),
programSource: document.querySelector(".gcode-header [data-program-source]")?.dataset.programSource || "",
rapidOverride: text('[data-value="rapid-override"]'),
feedOverride: text('[data-value="feed-override"]'),
spindleOverride: text('[data-value="spindle-override"]'),
halLast: text('[data-value="gmoccapy-hal-last"]'),
taskGates: text('[data-linuxcnc-task-policy="gates"]'),
taskHal: text('[data-task-hal-runtime="readiness"]'),
fullBoundary: text('[data-full-execution-boundary="status"]'),
gmoccapyComm: text('[data-gmoccapy-comm="boundary"]'),
gmoccapyHal: text('[data-gmoccapy-hal="boundary"]'),
nativePageMatrix: text('[data-gmoccapy-page="matrix"]'),
session: text('[data-session-persistence="status"]'),
operatorMessage: text("[data-operator-message]"),
},
};
});
}
async function collectButtonInventory() {
return page.evaluate(() => [...document.querySelectorAll("[data-action]")].map((element, index) => ({
index,
action: element.dataset.action,
tagName: element.tagName,
type: element.getAttribute("type") || "",
label: element.getAttribute("aria-label") || element.textContent?.trim() || "",
disabled: Boolean(element.disabled),
active: element.dataset.active || null,
commandReady: element.dataset.commandReady || null,
iconName: element.dataset.iconName || null,
iconVariant: element.dataset.iconVariant || null,
gmoccapyButtonId: element.dataset.gmoccapyButtonId || null,
nativePanel: element.dataset.gmoccapyNativePanel || null,
nativeButtonIndex: element.dataset.gmoccapyNativeButtonIndex || null,
halPin: element.dataset.gmoccapyHalPin || null,
})));
}
function addFinalChecks() {
const inventoryActions = new Set(report.buttonInventory.map((item) => item.action));
const testedActions = new Set(report.actionResults.map((item) => item.action));
const requiredActions = [
"select-profile",
"view-x",
"view-y",
"view-z",
"reset-view",
"clear-preview",
"select-linuxcnc-gcode-source",
"stage-linuxcnc-sources",
"OPEN",
"OPEN_FILE",
"RELOAD",
"RUN_READY",
"RUN",
"STOP",
"PAUSE",
"RESUME",
"STEP",
"HOME",
"JOG_X_NEG",
"JOG_X_POS",
"JOG_Y_NEG",
"JOG_Y_POS",
"MDI_RUN",
"SAVE_SESSION",
"RESTORE_SESSION",
"AUDIT_FULL_BOUNDARY",
"FULL",
"estop",
"power",
"reset",
"mode-auto",
"mode-manual",
"mode-jog",
"mode-mdi",
"kins-identity",
"kins-tcp",
"rapid-override-down",
"rapid-override-up",
"rapid-override-reset",
"feed-override-down",
"feed-override-up",
"feed-override-reset",
"ignore-limits",
"block-delete",
"optional-stop",
"toggle-flood",
"toggle-mist",
"spindle-forward",
"spindle-stop",
"spindle-reverse",
"spindle-override-down",
"spindle-override-up",
"spindle-override-reset",
"mdi-submit",
"mdi-history",
];
report.checks.push(
check("所有主界面区域存在", report.steps[0]?.dom?.regions && Object.values(report.steps[0].dom.regions).every(Boolean), JSON.stringify(report.steps[0]?.dom?.regions)),
check("canvas 非空且 WebGL ready", report.steps.every((step) => step.dom?.canvas?.threeReady === "true" && step.pixelStats.nonBlackRatio > 0.1), report.steps.map((step) => `${step.name}:${step.pixelStats.nonBlackRatio}`).join(", ")),
check("按钮库存包含核心 action", requiredActions.every((action) => inventoryActions.has(action) || action === "mdi-history" || action === "OPEN_FILE"), `missing=${requiredActions.filter((action) => !inventoryActions.has(action) && action !== "mdi-history" && action !== "OPEN_FILE").join(",")}`),
check("核心按钮均执行验证", requiredActions.every((action) => testedActions.has(action) || action === "OPEN_FILE"), `missing=${requiredActions.filter((action) => !testedActions.has(action) && action !== "OPEN_FILE").join(",")}`),
check("未发生页面异常", pageErrors.length === 0, pageErrors.join(" | ") || "-"),
check("所有 action 断言通过", report.actionResults.every((item) => item.status === "PASS"), report.actionResults.filter((item) => item.status !== "PASS").map((item) => `${item.action}:${item.error || item.checks?.map((checkItem) => checkItem.detail).join(";")}`).join(" | ") || "-"),
);
}
async function writeMarkdownReport(markdownPath, data) {
const lines = [];
lines.push("# working7 全量功能实际执行验证报告");
lines.push("");
lines.push(`生成时间:${data.generatedAt}`);
lines.push(`job_id${data.jobId}`);
lines.push(`report_id${data.reportId}`);
lines.push(`目标页面:${data.targetUrl}`);
lines.push(`总体结果:${data.status}`);
lines.push("");
lines.push("## 我验证的范围");
lines.push("");
lines.push("我在真实 Chromium 页面中逐项点击并验证主界面按钮、右侧全班按钮、程序执行、刀具预览、刀具实时执行路径、G-code 执行过程、机床轴值、倍率、主轴冷却、会话和诊断边界。");
lines.push("");
lines.push("## 总体验收");
lines.push("");
lines.push("| 检查项 | 结果 | 证据 |");
lines.push("| --- | --- | --- |");
for (const item of data.checks) {
lines.push(`| ${escapeMarkdown(item.name)} | ${item.pass ? "PASS" : "FAIL"} | ${escapeMarkdown(item.detail)} |`);
}
lines.push("");
lines.push("## 按钮执行矩阵");
lines.push("");
lines.push("| action | 我执行的操作 | 结果 | 关键状态变化 |");
lines.push("| --- | --- | --- | --- |");
for (const action of data.actionResults) {
lines.push(`| ${escapeMarkdown(action.action)} | ${escapeMarkdown(action.label)} | ${action.status} | ${escapeMarkdown(action.checks.map((item) => item.detail).join("").slice(0, 500))} |`);
}
lines.push("");
lines.push("## 图文证据");
lines.push("");
for (const step of data.steps) {
lines.push(`### ${step.name} ${step.title}`);
lines.push("");
lines.push(step.description);
lines.push("");
lines.push(`![${escapeMarkdown(step.title)}](${path.relative(path.dirname(markdownPath), step.screenshotPath)})`);
lines.push("");
lines.push("```json");
lines.push(JSON.stringify({
runState: step.state.runState,
machineProfile: step.state.machineProfile,
machine: step.state.machine,
axisPose: step.state.axisPose,
dro: step.state.dro,
canvas: step.dom.canvas,
gcode: step.dom.gcode,
values: step.dom.values,
}, null, 2));
lines.push("```");
lines.push("");
}
await fs.writeFile(markdownPath, `${lines.join("\n")}\n`, "utf8");
}
async function writePdfReport(pdfPath, data) {
const reportPage = await browser.newPage();
const checkRows = data.checks.map((item) => `
<tr><td>${escapeHtml(item.name)}</td><td class="${item.pass ? "pass" : "fail"}">${item.pass ? "PASS" : "FAIL"}</td><td>${escapeHtml(item.detail)}</td></tr>
`).join("");
const actionRows = data.actionResults.map((item) => `
<tr><td>${escapeHtml(item.action)}</td><td>${escapeHtml(item.label)}</td><td class="${item.status === "PASS" ? "pass" : "fail"}">${escapeHtml(item.status)}</td><td>${escapeHtml(item.checks.map((checkItem) => checkItem.detail).join("").slice(0, 700))}</td></tr>
`).join("");
const steps = data.steps.map((step) => `
<section>
<h2>${escapeHtml(step.name)} ${escapeHtml(step.title)}</h2>
<p>${escapeHtml(step.description)}</p>
<p><strong>截图:</strong>${escapeHtml(step.screenshotPath)}</p>
<img src="file://${step.screenshotPath}" />
<pre>${escapeHtml(JSON.stringify({
runState: step.state.runState,
machineProfile: step.state.machineProfile,
machine: step.state.machine,
axisPose: step.state.axisPose,
dro: step.state.dro,
gcode: step.dom.gcode,
canvas: {
threeReady: step.dom.canvas.threeReady,
threeSelectedView: step.dom.canvas.threeSelectedView,
threePathPoints: step.dom.canvas.threePathPoints,
threeExecutedPathPoints: step.dom.canvas.threeExecutedPathPoints,
threeToolExecutionTraceSource: step.dom.canvas.threeToolExecutionTraceSource,
},
}, null, 2))}</pre>
</section>
`).join("");
await reportPage.setContent(`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { font-family: Arial, "Noto Sans CJK SC", sans-serif; margin: 24px; color: #17202a; }
h1 { font-size: 22px; margin-bottom: 6px; }
h2 { font-size: 15px; margin-top: 18px; }
p, li { font-size: 11px; line-height: 1.45; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #9aa5b1; padding: 5px; font-size: 9px; vertical-align: top; }
th { background: #eef2f7; }
.pass { color: #126b37; font-weight: 700; }
.fail { color: #a61b1b; font-weight: 700; }
section { break-inside: avoid; border-top: 1px solid #d8dee6; padding-top: 8px; }
img { width: 100%; max-height: 620px; object-fit: contain; border: 1px solid #ccd3dc; }
pre { white-space: pre-wrap; font-size: 8px; background: #f7f9fc; padding: 6px; border: 1px solid #d8dee6; }
</style>
</head>
<body>
<h1>working7 全量功能实际执行验证报告</h1>
<p>我在真实 Chromium 页面中逐项点击并验证按钮、程序运行、刀具预览、实时路径、G-code 执行过程、机床轴值和右侧控制区。</p>
<p><strong>结果:</strong>${escapeHtml(data.status)}<strong>job_id</strong>${escapeHtml(data.jobId)}<strong>report_id</strong>${escapeHtml(data.reportId)}</p>
<p><strong>目标页面:</strong>${escapeHtml(data.targetUrl)}</p>
<h2>总体验收</h2>
<table><thead><tr><th>检查项</th><th>结果</th><th>证据</th></tr></thead><tbody>${checkRows}</tbody></table>
<h2>按钮执行矩阵</h2>
<table><thead><tr><th>action</th><th>操作</th><th>结果</th><th>状态变化</th></tr></thead><tbody>${actionRows}</tbody></table>
<h2>图文证据</h2>
${steps}
</body>
</html>`, { waitUntil: "load" });
await reportPage.pdf({
path: pdfPath,
format: "A4",
printBackground: true,
margin: { top: "10mm", right: "9mm", bottom: "10mm", left: "9mm" },
});
await reportPage.close();
}
function summarizeState(state = {}) {
return {
machineProfile: state.machineProfile,
activeProgram: state.activeProgram,
programSource: state.programSource,
programExecutionSourceMode: state.programExecutionSourceMode,
runState: state.runState,
activeLine: state.activeLine,
machine: {
powerOn: state.machine?.powerOn,
estopActive: state.machine?.estopActive,
taskState: state.machine?.taskState,
mode: state.machine?.mode,
allHomed: state.machine?.allHomed,
interpState: state.machine?.interpState,
taskPaused: state.machine?.taskPaused,
mdiCommand: state.machine?.mdiCommand,
},
axisPose: pickAxes(state.axisPose),
dro: pickAxes(state.dro),
tcpPose: pickAxes(state.tcpPose),
kinsType: state.kinsType,
rtcpState: state.rtcpState,
feed: {
rapidOverride: Number(state.feed?.rapidOverride),
feedOverride: Number(state.feed?.feedOverride),
feedRate: Number(state.feed?.feedRate),
currentVelocity: Number(state.feed?.currentVelocity),
},
spindle: {
enabled: Boolean(state.spindle?.enabled),
direction: state.spindle?.direction,
override: Number(state.spindle?.override),
rpm: Number(state.spindle?.rpm),
},
coolant: {
flood: Boolean(state.coolant?.flood),
mist: Boolean(state.coolant?.mist),
},
preview: {
pathPoints: Number(state.preview?.pathPoints),
selectedView: state.preview?.selectedView,
fullscreen: Boolean(state.preview?.fullscreen),
},
gmoccapyGui: {
ignoreLimits: Boolean(state.gmoccapyGui?.ignoreLimits),
optionalBlocks: Boolean(state.gmoccapyGui?.optionalBlocks),
optionalStop: Boolean(state.gmoccapyGui?.optionalStop),
activeNativePage: state.gmoccapyGui?.activeNativePage,
lastHalPinEffect: state.gmoccapyGui?.lastHalPinEffect,
},
mdiHistory: state.mdiHistory || [],
sessionPersistence: {
status: state.sessionPersistence?.status,
storageMode: state.sessionPersistence?.storageMode,
path: state.sessionPersistence?.path,
savedAt: state.sessionPersistence?.savedAt,
restoredAt: state.sessionPersistence?.restoredAt,
},
machineFileStaging: {
status: state.machineFileStaging?.status,
fileCount: state.machineFileStaging?.fileCount,
selectedGcodeSourceRel: state.machineFileStaging?.selectedGcodeSourceRel,
gcodeSourceCount: state.machineFileStaging?.gcodeSources?.length || 0,
},
taskHalStatus: {
taskCycle: Number(state.taskHalStatus?.ui?.taskCycle || 0),
servoCycle: Number(state.taskHalStatus?.ui?.servoCycle || 0),
activeLine: Number(state.taskHalStatus?.ui?.activeLine || 0),
singleStepping: Boolean(state.taskHalStatus?.task?.singleStepping),
},
programRuntimeFeedback: state.programRuntimeFeedback ? {
sourceMode: state.programRuntimeFeedback.sourceMode,
line: state.programRuntimeFeedback.line,
sampleIndex: state.programRuntimeFeedback.sampleIndex,
motionIndex: state.programRuntimeFeedback.motionIndex,
currentVelocityMmPerMin: state.programRuntimeFeedback.currentVelocityMmPerMin,
axisPose: pickAxes(state.programRuntimeFeedback.axisPose),
} : null,
operatorMessage: state.operatorMessage,
};
}
function summarizeDom(dom) {
if (!dom) return null;
return {
actionCount: dom.actionCount,
gcode: dom.gcode,
values: dom.values,
canvas: {
threeReady: dom.canvas?.threeReady,
threeSelectedView: dom.canvas?.threeSelectedView,
threePathPoints: dom.canvas?.threePathPoints,
threeExecutedPathPoints: dom.canvas?.threeExecutedPathPoints,
threeRtcpState: dom.canvas?.threeRtcpState,
},
};
}
function pickAxes(value = {}) {
return {
x: Number(value?.x || 0),
y: Number(value?.y || 0),
z: Number(value?.z || 0),
a: Number(value?.a || 0),
b: Number(value?.b || 0),
c: Number(value?.c || 0),
};
}
async function analyzePng(filePath) {
const png = PNG.sync.read(await fs.readFile(filePath));
let luminanceSum = 0;
let nonBlack = 0;
for (let index = 0; index < png.data.length; index += 4) {
const luminance = png.data[index] * 0.2126 + png.data[index + 1] * 0.7152 + png.data[index + 2] * 0.0722;
luminanceSum += luminance;
if (luminance > 8) nonBlack += 1;
}
const total = png.width * png.height;
return {
width: png.width,
height: png.height,
averageLuminance: Number((luminanceSum / total).toFixed(2)),
nonBlackRatio: Number((nonBlack / total).toFixed(4)),
};
}
function check(name, pass, detail) {
return { name, pass: Boolean(pass), detail: String(detail ?? "-") };
}
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 filePath = targetPath;
let stat = await fs.stat(filePath).catch(() => null);
if (stat?.isDirectory()) {
filePath = path.join(filePath, "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));
}
});
}
function contentTypeFor(filePath) {
const ext = path.extname(filePath).toLowerCase();
return {
".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",
".png": "image/png",
".wasm": "application/wasm",
".xml": "application/xml; charset=utf-8",
}[ext] || "application/octet-stream";
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeMarkdown(value) {
return String(value ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}