1115 lines
57 KiB
JavaScript
1115 lines
57 KiB
JavaScript
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 变为 active,MANUAL 变为 inactive,power/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(`, 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function escapeMarkdown(value) {
|
||
return String(value ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
||
}
|
||
|
||
function wait(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|