Files
linuxcncWebWASM/docs/user-manual/capture_deployed_manual.mjs
wangdequan db29ae625f 按关节槽位驱动 13 个五轴案例的三维模型
关节契约扩为槽位制:以 LinuxCNC 联合号标识关节,轴字母降为槽位属性,
重复轴(bridge-mill 的两个 Y)、并联杆长(hexapod/pentapod)和被动槽位
(max5 第 6 关节)都能表达。v1 规则保持不变并合成 jointOrder,已保存
GLB 与既有测试不受影响。

13 个结构 GLB 由 scripts/generate_physical_case_glbs.mjs 统一重建,
可动关节均为按槽位命名的 Group 并写 userData.linuxcncJoint,共 71 个
关节,槽位数与运行目录的 joint_count 逐条一致。

新增 machine-visual-bindings.json 与纯函数模块,以 machine id 为键:
坐标字母不能作键,因为三个案例共用 XYZAB、三个共用 XYZABC。主界面切换
案例时自动装载对应模型,失败时保留内置模型并显式提示。

消除前端五关节截断:visual 适配器的 joints 改为数组并新增
jointsAuthoritative;allHomed 按 joint_count 判断;面板按 worker 断言的
machine id 查找机型。

管理器解锁:坐标与案例下拉以"存在完整视觉绑定"为启用条件,13 案例全部
可选;machine-urdf.ts 新增 generic-wasm/1 profile,AC/BC 严格校验路径
不变;新增 machine-kinematics-specs.json 保存逐关节限位,INI 未声明的
限位保持 null 而不编造。

修复三个真实缺陷:构建漏发 woodpecker/gmoccapy/qtdragon 配置目录(
DEFECT-02 根因);案例切换沿用"装载需要已上电"的门控,导致关机状态下
第二次切换被静默拒绝;重复轴机型上 React 列表键冲突。

验收:13 案例逐个切换装载成功、槽位键与绑定定义逐一一致、浏览器错误 0;
typecheck 与生产构建通过;视觉绑定、GLB 命名、关节契约、投影、store、
双画布、运动学规格漂移、11 案例 Worker 闭环、管理器浏览器回归全部通过。
证据在 coverage/glb-machine-manager/physical-drive/。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 06:09:12 +08:00

555 lines
26 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.
// 对线上发布站点做功能巡检并采集使用说明书插图。
//
// 运行:
// PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs \
// node docs/user-manual/capture_deployed_manual.mjs
//
// 可选环境变量:MANUAL_URL(默认线上 58010 站点)、CHROME_PATH。
// 输出:docs/user-manual/screenshots/*.png 与 docs/user-manual/deployment-test.report.json
//
// 结构:
// 阶段一 在 XYZAC 上跑完整操作链(上电、回零、Jog、主轴、MDI、程序运行等)。
// 阶段二 逐案例探测仿真案例在浏览器中的可用性(每案例独立页面)。
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const shotDir = resolve(here, "screenshots");
const reportPath = resolve(here, "deployment-test.report.json");
const baseUrl = process.env.MANUAL_URL ?? "https://49.232.250.35:58010/";
const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
const playwrightModule = process.env.PLAYWRIGHT_MODULE;
const results = [];
const consoleErrors = [];
const failedRequests = [];
const artifacts = [];
const defects = [];
const caseMatrix = [];
await mkdir(shotDir, { recursive: true });
const { chromium } = await import(playwrightModule);
const browser = await chromium.launch({
executablePath: chromePath,
args: ["--no-sandbox", "--ignore-certificate-errors", "--enable-unsafe-swiftshader"],
});
const newPage = async () => {
const page = await browser.newPage({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 } });
page.on("pageerror", (error) => consoleErrors.push({ kind: "pageerror", text: String(error) }));
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push({ kind: "console", text: message.text() }); });
page.on("requestfailed", (request) => failedRequests.push(`${request.url()} ${request.failure()?.errorText ?? ""}`));
page.on("response", (response) => { if (response.status() >= 400) failedRequests.push(`HTTP ${response.status()} ${response.url()}`); });
return page;
};
const page = await newPage();
const step = async (name, body) => {
const started = Date.now();
try {
const detail = await body();
results.push({ name, status: "pass", ms: Date.now() - started, detail: detail ?? null });
console.log(`PASS ${name}${detail ? ` — ${typeof detail === "string" ? detail : JSON.stringify(detail)}` : ""}`);
} catch (error) {
results.push({ name, status: "fail", ms: Date.now() - started, detail: String(error?.message ?? error) });
console.log(`FAIL ${name} — ${error?.message ?? error}`);
}
};
const shot = async (name, { full = false } = {}) => {
await page.screenshot({ path: resolve(shotDir, `${name}.png`), fullPage: full });
artifacts.push(`${name}.png`);
};
const text = async (selector) => (await page.locator(selector).first().innerText()).replace(/\s+/g, " ").trim();
const statusText = () => text(".machine-status");
const positions = async () => page.locator(".xyzac-position-grid output").allInnerTexts();
const settle = async (ms = 600) => { await page.waitForTimeout(ms); };
const closeDialog = async () => {
const dialog = page.locator("dialog[open]").first();
if (!(await dialog.count())) return;
const button = dialog.locator('header button[aria-label^="关闭"]').first();
if (await button.count()) await button.click();
else await page.keyboard.press("Escape");
await dialog.waitFor({ state: "hidden", timeout: 5000 }).catch(() => {});
};
const waitFor = async (predicate, timeout, label) => {
const deadline = Date.now() + timeout;
let last = "";
while (Date.now() < deadline) {
last = await predicate();
if (last) return last;
await page.waitForTimeout(400);
}
throw new Error(`${label} 在 ${timeout}ms 内未满足,最后观察:${String(last).slice(0, 160)}`);
};
const waitState = async (pattern, timeout = 60000) => {
const deadline = Date.now() + timeout;
let last = "";
while (Date.now() < deadline) {
last = await statusText();
if (pattern.test(last)) return last;
await page.waitForTimeout(400);
}
throw new Error(`状态 ${pattern} 在 ${timeout}ms 内未满足,最后状态:${last.slice(0, 200)}`);
};
const waitCase = (timeout = 120000) => waitFor(async () => {
const value = (await page.locator(".case-status").innerText()).trim();
return value.startsWith("Program:") ? value : null;
}, timeout, "案例程序加载完成");
// 重新载入案例会按设计先给机床下电并复位回零状态,运行程序前需要重新上电并回零。
const ensureReady = async () => {
const estop = page.locator('[data-axis-control="toolbar-estop"]');
if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await settle(1500); }
const power = page.locator('[data-axis-control="toolbar-power"]');
if ((await power.getAttribute("aria-label"))?.includes("Power on") && !(await power.isDisabled())) {
await power.click();
await waitState(/STATE_ON|STATE_IDLE/, 40000);
}
if (!/Machine sequence HOMED/.test(await statusText())) {
await page.locator('[data-axis-control="tab-manual"]').click();
await page.locator('[data-axis-control="manual-home-all"]').click();
await waitState(/Machine sequence HOMED/, 60000);
}
return statusText();
};
// canLoad 为假时下拉框会拒绝切换,这里按值重试直到状态真正生效。
const selectCase = async (value, timeout = 45000) => {
const select = page.locator('[data-axis-control="workspace-case-select"]');
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
await select.selectOption(value);
await settle(2500);
if ((await select.inputValue()) === value) { await waitCase(); await settle(2500); return value; }
}
throw new Error(`案例切换未生效:${value}`);
};
// 冷却液等开关是受 WASM 状态回传驱动的受控复选框,点击后要等状态回传再判断,避免误判失败。
const setToggle = async (selector, target, attempts = 6) => {
const box = page.locator(selector);
for (let attempt = 0; attempt < attempts; attempt += 1) {
if ((await box.isChecked()) === target) return true;
await box.click();
await settle(1200);
}
return (await box.isChecked()) === target;
};
// ---------------------------------------------------------------- 阶段一:TRT 主线
await step("打开线上站点并等待 WASM 会话就绪", async () => {
await page.goto(baseUrl, { waitUntil: "load", timeout: 90000 });
await page.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
await waitState(/STATE_/, 60000);
await waitCase(90000);
await settle(2500);
const cases = await page.locator('[data-axis-control="workspace-case-select"] option').allInnerTexts();
await shot("01-main-overview");
return { title: await page.title(), canvases: await page.locator("canvas").count(), cases: cases.length };
});
await step("状态栏、轴位与实时参数由 LinuxCNC WASM 驱动", async () => {
const status = await statusText();
const live = await page.locator("[data-xyzac-parameter-source]").first().getAttribute("data-xyzac-parameter-source");
const axes = await positions();
if (!/Task mode/.test(status)) throw new Error(`状态栏异常:${status}`);
if (live !== "linuxcnc-wasm") throw new Error(`实时参数来源不是 WASM:${live}`);
if (axes.length < 5) throw new Error(`轴位显示数量不足:${axes.length}`);
return { live, axes, status: status.slice(0, 120) };
});
await step("Help 菜单可打开 About 对话框", async () => {
await page.locator('[data-axis-control="menu-help"]').click();
await page.locator('[data-axis-control="menu-help-about"]').click();
const dialog = page.locator(".axis-about-dialog");
await dialog.waitFor({ state: "visible", timeout: 5000 });
await shot("02-about-dialog");
const body = (await dialog.innerText()).replace(/\s+/g, " ");
await page.locator('[data-axis-control="about-close"]').click();
await dialog.waitFor({ state: "hidden", timeout: 5000 });
return body.slice(0, 120);
});
await step("File 菜单把宿主边界项正确置灰", async () => {
await page.locator('[data-axis-control="menu-file"]').click();
const states = [
await page.locator('[data-axis-control="menu-file-edit-tool-data"]').isDisabled(),
await page.locator('[data-axis-control="menu-file-quit"]').isDisabled(),
];
await shot("03-file-menu");
await page.keyboard.press("Escape");
if (states.some((disabled) => !disabled)) throw new Error(`宿主边界项未置灰:${states}`);
return states;
});
await step("RTCP 程序库按机型过滤并列出可加载程序", async () => {
await page.locator('[data-axis-control="workspace-rtcp-programs"]').click();
const panel = page.locator("dialog.rtcp-program-dialog");
await panel.waitFor({ state: "visible", timeout: 8000 });
await settle(600);
await shot("04-rtcp-program-library");
const summary = (await panel.locator(".rtcp-program-summary").innerText()).replace(/\s+/g, " ");
const options = await panel.locator('[data-axis-control="rtcp-program-select"] option').count();
const scopes = await panel.locator('[data-axis-control="rtcp-program-scope"] option').allInnerTexts();
await closeDialog();
if (options < 3) throw new Error(`RTCP 程序条目不足:${options}`);
return { options, summary, scopes };
});
await step("导入机床模型对话框列出内置示例与下载入口", async () => {
await page.locator('[data-axis-control="machine-glb-settings"]').click();
const dialog = page.locator("dialog[open]");
await dialog.waitFor({ state: "visible", timeout: 8000 });
await settle(800);
await shot("05-machine-import-dialog");
const downloads = await dialog.locator("a[download]").count();
const summary = (await dialog.innerText()).replace(/\s+/g, " ");
await closeDialog();
return { downloads, summary: summary.slice(0, 160) };
});
await step("三维机床管理四个步骤页签可切换", async () => {
await page.locator('[data-axis-control="machine-manager-settings"]').click();
const dialog = page.locator("dialog[open]");
await dialog.waitFor({ state: "visible", timeout: 8000 });
await settle(1200);
const seen = [];
for (const [id, name] of [["structure", "06-machine-manager-structure"], ["motion", "07-machine-manager-motion"], ["calibration", "08-machine-manager-calibration"], ["files", "09-machine-manager-files"]]) {
await page.locator(`[data-axis-control="machine-manager-tab-${id}"]`).click();
await settle(900);
await shot(name);
seen.push((await text(`#machine-panel-${id}`)).slice(0, 70));
}
await closeDialog();
return seen;
});
await step("机床参数对话框显示旋转中心与轴间偏置字段", async () => {
await page.locator('[data-axis-control="geometry-settings"]').click();
const dialog = page.locator("dialog[open]");
await dialog.waitFor({ state: "visible", timeout: 8000 });
await settle(600);
await shot("10-geometry-parameters");
const fieldCount = await dialog.locator('input[type="number"]').count();
await closeDialog();
if (fieldCount < 4) throw new Error(`几何参数字段不足:${fieldCount}`);
return fieldCount;
});
await step("案例切换到 XYZBC 后机型与轴组同步改变", async () => {
await selectCase("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini");
await shot("11-case-xyzbc");
const axes = await page.locator(".axis-radio span").allInnerTexts();
if (axes.join("") !== "XYZBC") throw new Error(`切换机型后轴组异常:${axes}`);
await selectCase("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini");
const back = await page.locator(".axis-radio span").allInnerTexts();
if (back.join("") !== "XYZAC") throw new Error(`切回 XYZAC 失败:${back}`);
return { axes, back };
});
await step("急停复位并可上电", async () => {
const estop = page.locator('[data-axis-control="toolbar-estop"]');
if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await settle(1500); }
await page.locator('[data-axis-control="toolbar-power"]').click();
return (await waitState(/STATE_ON|STATE_IDLE/, 40000)).slice(0, 120);
});
await step("HOME ALL 完成全部关节回零", async () => {
await page.locator('[data-axis-control="manual-home-all"]').click();
const status = await waitState(/Machine sequence HOMED/, 60000);
await shot("12-manual-homed");
return status.slice(0, 120);
});
await step("连续 Jog:按住 X 正向位置变化,松手停止", async () => {
await page.locator('[data-axis-control="manual-axis-x"]').check();
const before = (await positions())[0];
await page.locator('[data-axis-control="manual-jog-positive"]').hover();
await page.mouse.down();
await settle(1800);
await shot("13-manual-jog");
await page.mouse.up();
await settle(1500);
const after = (await positions())[0];
if (before === after) throw new Error(`连续 Jog 后 X 未变化:${before}`);
return { before, after };
});
await step("增量 Jog:0.1000 步长按一次步进", async () => {
await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("0.1000");
const before = Number((await positions())[0]);
await page.locator('[data-axis-control="manual-jog-negative"]').hover();
await page.mouse.down();
await settle(1200);
await page.mouse.up();
await settle(2000);
const after = Number((await positions())[0]);
await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("Continuous");
if (!(Math.abs(Math.abs(before - after) - 0.1) < 0.04)) throw new Error(`增量步长不符:${before} -> ${after}`);
return { before, after };
});
await step("主轴启停与冷却液开关可用", async () => {
await page.locator('[data-axis-control="tab-manual"]').click();
await page.locator('[data-axis-control="manual-spindle-cw"]').click();
await settle(2500);
const live = await text('[data-axis-feature="AXIS-LIVE-PARAMETERS"]');
const mistOn = await setToggle('[data-axis-control="manual-coolant-mist"]', true);
await shot("14-spindle-coolant");
await page.locator('[data-axis-control="manual-spindle-stop"]').click();
const mistOff = await setToggle('[data-axis-control="manual-coolant-mist"]', false);
if (!mistOn || !mistOff) throw new Error(`冷却液开关未按预期切换:开=${mistOn} 关=${mistOff}`);
return { mistOn, mistOff, live: live.slice(0, 110) };
});
await step("MDI 指令执行并写入历史", async () => {
await page.locator('[data-axis-control="tab-mdi"]').click();
await page.locator('[data-axis-control="mdi-command"]').fill("G0 X12.5 Y-4 Z0");
await page.locator('[data-axis-control="mdi-submit"]').click();
await settle(3000);
await shot("15-mdi");
const history = await page.locator('[data-axis-control="mdi-history-select"]').first().innerText();
if (!history.includes("G0")) throw new Error(`MDI 历史缺记录:${history}`);
return history;
});
await step("DRO 视图可切换坐标、显示与单位", async () => {
await page.locator('[data-axis-control="tab-dro"]').click();
await page.locator('[data-axis-control="dro-coordinate"]').selectOption("relative");
await page.locator('[data-axis-control="dro-display"]').selectOption("commanded");
await page.locator('[data-axis-control="dro-units"]').selectOption("inch");
await settle(1500);
await shot("16-dro");
const rows = await page.locator(".dro-row").count();
const footer = await text(".dro-footer");
await page.locator('[data-axis-control="dro-units"]').selectOption("mm");
await page.locator('[data-axis-control="dro-coordinate"]').selectOption("machine");
await page.locator('[data-axis-control="dro-display"]').selectOption("actual");
await settle(800);
return { rows, footer: footer.slice(0, 110) };
});
await step("倍率滑块与预览图层开关可用", async () => {
await page.locator('[data-axis-control="override-feed"]').fill("80");
await settle(1500);
const label = (await page.locator("section.axis-metrics").innerText()).replace(/\s+/g, " ");
const layer = page.locator('[data-axis-control="preview-layer-velocity"]');
await layer.uncheck();
await settle(500);
const unchecked = !(await layer.isChecked());
await layer.check();
await shot("17-overrides-layers");
if (!unchecked) throw new Error("预览图层开关无效");
return label.slice(0, 150);
});
await step("工具栏『载入精确案例』按钮重新载入当前案例", async () => {
await page.locator('[data-axis-control="tab-preview"]').click();
await page.locator('[data-axis-control="toolbar-load-case"]').click();
// 修复前这里会停在 blocked: simulation_case_unknown,永远不会回到 Program: 状态。
const settled = await waitCase(120000);
const status = (await page.locator(".case-status").innerText()).trim();
if (/^blocked:/.test(status)) throw new Error(`载入精确案例失败:${status}`);
return status.slice(0, 80);
});
await step("载入案例程序并运行、暂停、继续、停止", async () => {
await ensureReady();
await page.locator('[data-axis-control="workspace-load-boat"]').click();
await waitCase();
await settle(3000);
await page.locator('[data-axis-control="toolbar-run"]').click();
const running = await waitState(/STATE_RUNNING|INTERP_READING|INTERP_WAITING/, 60000);
await settle(2500);
await shot("18-program-running");
let paused = "";
if (await page.locator('[data-axis-control="toolbar-pause-resume"]').isEnabled()) {
await page.locator('[data-axis-control="toolbar-pause-resume"]').click();
paused = await waitState(/PAUSED|INTERP_PAUSED/, 30000);
await settle(1500);
await shot("19-program-paused");
await page.locator('[data-axis-control="toolbar-pause-resume"]').click();
await settle(2500);
}
await page.locator('[data-axis-control="toolbar-stop"]').click();
await settle(3000);
return { running: running.slice(0, 100), paused: paused.slice(0, 100), stopped: (await statusText()).slice(0, 100) };
});
await step("RTCP 程序库『加载到控制器』可用", async () => {
await page.locator('[data-axis-control="workspace-rtcp-programs"]').click();
const panel = page.locator("dialog.rtcp-program-dialog");
await panel.waitFor({ state: "visible", timeout: 8000 });
await settle(800);
try {
const load = panel.locator('[data-axis-control="rtcp-program-load"]');
if (!(await load.isEnabled())) throw new Error("默认主程序当前不可加载");
await load.click();
await panel.locator('output[role="status"]').filter({ hasText: "程序已加载" }).waitFor({ state: "visible", timeout: 60000 });
await shot("22-rtcp-load");
return (await panel.locator('output[role="status"]').innerText()).trim();
} finally {
await closeDialog();
await settle(2500);
}
});
await step("工具栏『打开 G 代码文件』可装载本地 .ngc", async () => {
await page.setInputFiles('[data-axis-control="program-file-input"]', "web/react/public/machine-cases/xyzac-trt.ngc");
await waitCase(90000);
await settle(2500);
return (await page.locator(".case-status").innerText()).trim();
});
await step("工具栏『重载当前程序』可用", async () => {
await page.locator('[data-axis-control="toolbar-reload"]').click();
await settle(4000);
return (await statusText()).slice(0, 110);
});
await step("工具栏视图按钮与清除轨迹可用", async () => {
for (const id of ["toolbar-view-z", "toolbar-view-x", "toolbar-view-y", "toolbar-view-p"]) {
await page.locator(`[data-axis-control="${id}"]`).click();
await settle(500);
}
const pressed = await page.locator('[data-axis-control="toolbar-view-p"]').getAttribute("aria-pressed");
await page.locator('[data-axis-control="toolbar-rotate"]').click();
await page.locator('[data-axis-control="toolbar-clear-live-plot"]').click();
await settle(1200);
await shot("23-views-cleared");
if (pressed !== "true") throw new Error(`视图按钮未置为按下:${pressed}`);
return { pressed };
});
await step("诊断页显示启动资源与门控", async () => {
await page.locator('[data-axis-control="tab-diagnostics"]').click();
await settle(1200);
await shot("20-diagnostics");
const body = await text("#axis-panel-diagnostics");
if (!/Disabled operations|Startup resources/.test(body)) throw new Error(`诊断页内容异常:${body.slice(0, 100)}`);
return body.slice(0, 150);
});
await step("快捷键 F1 / F3 / F5 生效", async () => {
await page.locator("body").click({ position: { x: 5, y: 5 } });
await page.keyboard.press("F1");
const estop = await waitState(/ESTOP/, 15000);
await page.keyboard.press("F1");
await settle(2000);
await page.keyboard.press("F3");
await settle(800);
const manualSelected = await page.locator('[data-axis-control="tab-manual"]').getAttribute("aria-selected");
await page.keyboard.press("F5");
await settle(800);
const mdiSelected = await page.locator('[data-axis-control="tab-mdi"]').getAttribute("aria-selected");
if (manualSelected !== "true" || mdiSelected !== "true") throw new Error(`F3/F5 未切换页签:${manualSelected}/${mdiSelected}`);
return { estopTriggered: /ESTOP/.test(estop), manualSelected, mdiSelected };
});
await step("窄视口 390x844 无横向溢出", async () => {
await page.setViewportSize({ width: 390, height: 844 });
await settle(2500);
await shot("21-mobile-390", { full: true });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
await page.setViewportSize({ width: 1440, height: 900 });
if (overflow > 2) throw new Error(`出现横向溢出 ${overflow}px`);
return { overflow };
});
await page.close();
// ---------------------------------------------------------------- 阶段二:案例矩阵
const MATRIX = [
["xyzac-trt", "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"],
["xyzbc-trt", "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"],
["xyzab-tdr", "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini"],
["max5-maxkins", "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini"],
["trivkins-xyzya", "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini"],
["hexapod-genhexkins", "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini"],
["scorbot-kins", "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini"],
];
console.log("\n阶段二:案例可用性矩阵(每个案例独立页面)");
for (const [id, entrypoint] of MATRIX) {
const probe = await newPage();
const fourXx = [];
probe.on("response", (response) => { if (response.status() >= 400) fourXx.push(`${response.status()} ${response.url().replace(baseUrl.replace(/\/$/, ""), "")}`); });
try {
await probe.goto(baseUrl, { waitUntil: "load", timeout: 90000 });
await probe.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
await probe.waitForFunction(() => document.querySelector(".case-status")?.textContent?.startsWith("Program:"), null, { timeout: 90000 });
await probe.waitForTimeout(4000);
const select = probe.locator('[data-axis-control="workspace-case-select"]');
let applied = false;
for (let attempt = 0; attempt < 5 && !applied; attempt += 1) {
await select.selectOption(entrypoint);
await probe.waitForTimeout(2500);
applied = (await select.inputValue()) === entrypoint;
}
await probe.waitForTimeout(20000);
const power = probe.locator('[data-axis-control="toolbar-power"]');
const usable = applied && !(await power.isDisabled());
const status = (await probe.locator(".machine-status").innerText()).replace(/\s+/g, " ");
caseMatrix.push({
id,
applied,
power: usable ? "enabled" : `disabled:${await power.getAttribute("title")}`,
state: status.slice(0, 90),
http_4xx: fourXx.slice(0, 2),
verdict: usable ? "usable" : applied ? "loads_but_controller_pending" : "switch_rejected",
});
console.log(` ${usable ? "可用 " : "不可用"} ${id.padEnd(20)} ${usable ? "可上电" : status.slice(0, 46)}`);
} catch (error) {
caseMatrix.push({ id, applied: false, power: "unknown", state: String(error?.message ?? error).slice(0, 120), http_4xx: fourXx.slice(0, 2), verdict: "probe_error" });
console.log(` 探测失败 ${id} — ${String(error?.message ?? error).slice(0, 80)}`);
} finally {
await probe.close();
}
}
const pendingCases = caseMatrix.filter((row) => row.verdict !== "usable");
if (pendingCases.length) {
defects.push({
id: "DEFECT-02",
severity: "high",
control: "Simulation case 下拉框中的通用运动学案例",
observed: `${pendingCases.map((row) => row.id).join("、")} 载入后控制器停在 PENDING,电源按钮保持禁用(linuxcnc_wasm_snapshot_pending),此时下拉框无法切回其它案例,只能刷新页面恢复`,
expected: "选中案例后可上电、回零并运行其干运行程序",
root_cause: "通用案例运行链在浏览器侧尚未完成:案例的运动学源文件(如 linuxcnc/src/emc/kinematics/trivkins.c、linuxcnc/src/hal/components/xyzab_tdr_kins.comp)没有随前端产物发布,请求返回 404;且会话启动失败后前端不回滚,卡在 pending",
workaround: "当前只使用 XYZAC TRT / XYZBC TRT 两个案例;误选其它案例后刷新浏览器页面",
});
}
const report = {
report_id: "LINUXCNC-WEB-DEPLOYED-MANUAL-TEST",
url: baseUrl,
checked_at: new Date().toISOString(),
// 只有步骤失败才判定 failed;已知缺陷(DEFECT-xx)单独记录,不掩盖功能测试结论。
status: results.every((row) => row.status === "pass") ? "verified" : "failed",
passed: results.filter((row) => row.status === "pass").length,
failed: results.filter((row) => row.status === "fail").length,
results,
defects,
case_matrix: caseMatrix,
console_errors: consoleErrors,
http_and_request_failures: failedRequests,
screenshots: artifacts,
};
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
console.log(`\n${report.status.toUpperCase()} ${report.passed} passed / ${report.failed} failed → ${reportPath}`);
if (defects.length) console.log(`缺陷/限制 ${defects.length} 项:`, defects.map((item) => item.id).join("、"));
if (consoleErrors.length) console.log("console/page errors:", JSON.stringify(consoleErrors.slice(0, 3)));
await browser.close();
process.exit(report.status === "verified" ? 0 : 1);