// 按"使用说明书"的操作顺序,在本地发布站点上完整走一遍标准使用流程,并逐步取证。 // // 运行: // PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs \ // node docs/user-manual/local-walkthrough.mjs // // 环境变量:WALKTHROUGH_URL(默认 http://127.0.0.1:5174/)、CHROME_PATH。 // WALKTHROUGH_HEADED=1 在桌面打开可见窗口执行(便于人工观察) // WALKTHROUGH_PACE_MS 每一步之间的停留时间,默认可见模式 1500ms、无头模式 0 // WALKTHROUGH_HOLD_MS 结束前保持窗口的时间,默认可见模式 20000ms、无头模式 0 // 输出:docs/user-manual/local-walkthrough/(截图 + 分步报告 JSON) 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 outDir = resolve(here, "local-walkthrough"); const baseUrl = process.env.WALKTHROUGH_URL ?? "http://127.0.0.1:5174/"; const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome"; const headed = process.env.WALKTHROUGH_HEADED === "1"; const pace = Number(process.env.WALKTHROUGH_PACE_MS ?? (headed ? 1500 : 0)); const hold = Number(process.env.WALKTHROUGH_HOLD_MS ?? (headed ? 20000 : 0)); await mkdir(outDir, { recursive: true }); const { chromium } = await import(process.env.PLAYWRIGHT_MODULE); const browser = await chromium.launch({ executablePath: chromePath, headless: !headed, args: headed ? ["--no-sandbox", "--start-maximized", "--disable-infobars", "--enable-unsafe-swiftshader"] : ["--no-sandbox", "--enable-unsafe-swiftshader"], }); const page = await browser.newPage({ viewport: headed ? null : { width: 1440, height: 900 } }); await page.bringToFront().catch(() => {}); const consoleErrors = []; page.on("pageerror", (error) => consoleErrors.push(String(error))); page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); const steps = []; const findings = []; const settle = (ms = 500) => page.waitForTimeout(ms); const text = async (selector) => (await page.locator(selector).first().innerText()).replace(/\s+/g, " ").trim(); const status = () => text(".machine-status"); const command = () => text(".command-status"); const axes = async () => page.locator(".xyzac-position-grid output").allInnerTexts(); const caseStatus = async () => (await page.locator(".case-status").innerText()).trim(); const loadGateState = async () => { const button = page.locator('[data-axis-control="workspace-load-boat"]'); return (await button.isDisabled()) ? `blocked:${await button.getAttribute("title")}` : "enabled"; }; const machineSequence = async () => (await status()).match(/Machine sequence (NOT HOMED|HOMED)/)?.[1] ?? "-"; 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} 超时,最后观察:${String(last).slice(0, 160)}`); }; const waitStatus = async (pattern, timeout = 60000) => { const deadline = Date.now() + timeout; let last = ""; while (Date.now() < deadline) { last = await status(); if (pattern.test(last)) return last; await page.waitForTimeout(400); } throw new Error(`等待 ${pattern} 超时,最后状态:${last.slice(0, 160)}`); }; const waitCase = (timeout = 120000) => waitFor(async () => { const value = await caseStatus(); return value.startsWith("Program:") ? value : null; }, timeout, "案例程序就绪"); const record = async (index, title, body) => { const started = Date.now(); console.log(`\n▶ 第 ${index} 步 ${title}`); try { const detail = await body(); const shot = `step-${String(index).padStart(2, "0")}.png`; await page.screenshot({ path: resolve(outDir, shot) }); steps.push({ index, title, status: "ok", ms: Date.now() - started, detail, screenshot: shot }); console.log(` ✔ 完成${detail ? ` — ${JSON.stringify(detail)}` : ""}`); } catch (error) { const shot = `step-${String(index).padStart(2, "0")}-failed.png`; await page.screenshot({ path: resolve(outDir, shot) }).catch(() => {}); steps.push({ index, title, status: "failed", ms: Date.now() - started, detail: String(error?.message ?? error), screenshot: shot }); console.log(` ✘ 失败 — ${error?.message ?? error}`); } if (pace) await page.waitForTimeout(pace); }; console.log(`本地发布站点使用流程演练:${baseUrl}\n`); console.log(headed ? "模式:桌面可见窗口(可以看着它操作)" : "模式:无头浏览器"); // 1. 打开页面,等待 WASM 会话就绪 await record(1, "打开页面并等待仿真会话就绪", async () => { await page.goto(baseUrl, { waitUntil: "load", timeout: 60000 }); await page.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 }); await waitCase(90000); await waitStatus(/STATE_/, 60000); await settle(2000); return { 标题: await page.title(), 连接: (await text(".connection-state")), 默认案例: await page.locator('[data-axis-control="workspace-case-select"]').inputValue().then((v) => v.split("/").pop()), 当前程序: await caseStatus(), 可渲染画布: await page.locator("canvas").count(), }; }); // 2. 上电前:确认门控把运动操作锁住 await record(2, "上电前确认门控:手动与运动操作被锁", async () => { const jog = page.locator('[data-axis-control="manual-jog-positive"]'); const mdi = page.locator('[data-axis-control="mdi-command"]'); return { 任务状态: (await status()).match(/Task state (\S+)/)?.[1], 回零状态: await machineSequence(), 装载程序按钮: await loadGateState(), Jog按钮: await jog.isDisabled() ? `禁用(${await jog.getAttribute("title")})` : "可用", MDI输入: await mdi.isDisabled() ? "禁用" : "可用", }; }); // 3. 急停复位 + 上电 await record(3, "急停复位并上电", 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 reset = await status(); await page.locator('[data-axis-control="toolbar-power"]').click(); const on = await waitStatus(/STATE_ON|STATE_IDLE/, 40000); return { 复位后: reset.match(/Task state (\S+)/)?.[1], 上电后: on.match(/Task state (\S+)/)?.[1] }; }); // 4. 回零 await record(4, "HOME ALL 全部关节回零", async () => { await page.locator('[data-axis-control="manual-home-all"]').click(); await waitStatus(/Machine sequence HOMED/, 60000); await settle(1500); return { 回零状态: await machineSequence(), 轴位: await axes() }; }); // 5. 连续 Jog await record(5, "手动连续 Jog:选中 X 轴,按住 + 移动,松手停止", async () => { await page.locator('[data-axis-control="manual-axis-x"]').check(); const before = (await axes())[0]; await page.locator('[data-axis-control="manual-jog-positive"]').hover(); await page.mouse.down(); await settle(1800); await page.mouse.up(); await settle(1500); const moved = (await axes())[0]; await settle(1500); const stopped = (await axes())[0]; return { 移动前: before, 松开瞬间: moved, "1.5s后": stopped, 结论: moved !== before && stopped === moved ? "移动后停止" : "需复核" }; }); // 6. 增量 Jog await record(6, "增量 Jog:0.1000 步长点动一次", async () => { await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("0.1000"); const before = Number((await axes())[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 axes())[0]); await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("Continuous"); return { 步长: "0.1000 mm", 前: before, 后: after, 实际位移: Number((before - after).toFixed(4)) }; }); // 7. MDI await record(7, "MDI 执行 G0 X12.5 Y-4 Z0", 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(3500); return { 命令: "G0 X12.5 Y-4 Z0", 轴位: await axes(), 反馈: await command(), 历史: await page.locator('[data-axis-control="mdi-history-select"]').first().innerText() }; }); // 8. 载入案例自带的五轴程序 await record(8, "MDI 之后切回手动模式(否则装载门控保持失效)", async () => { const afterMdi = await loadGateState(); const modeBefore = (await status()).match(/Task mode (\S+)/)?.[1]; await page.locator('[data-axis-control="tab-manual"]').click(); await settle(3000); const afterManual = await loadGateState(); if (afterMdi.startsWith("blocked:xyzac_motion_active") && afterManual === "enabled") { findings.push({ id: "FINDING-01", severity: "medium", control: "MDI 之后的装载门控", observed: `MDI 执行完成后(Planner queued=0、Task mode=${modeBefore}),"Load XYZAC boat program" 仍被拒绝,提示 ${afterMdi};此时必须切回手动模式或刷新页面才能载入程序`, expectation: "MDI 运动结束、队列排空后装载程序应当自动恢复可用", workaround: "点一下左侧 Manual Control [F3] 页签(发出一条模式命令刷新门控),或刷新页面", }); } return { MDI后: afterMdi, 切回手动后: afterManual, 任务模式: (await status()).match(/Task mode (\S+)/)?.[1] }; }); // 9. 载入案例自带的五轴程序 await record(9, "载入 XYZAC 船体程序并生成刀路", async () => { await page.locator('[data-axis-control="tab-preview"]').click(); await page.locator('[data-axis-control="workspace-load-boat"]').click(); await waitCase(); await settle(3000); return { 程序: await caseStatus(), 反馈: await command() }; }); // 10. 运行程序并采样轴运动 await record(10, "运行程序,观察解释器与轴运动", async () => { await page.locator('[data-axis-control="toolbar-run"]').click(); await waitStatus(/STATE_RUNNING|INTERP_READING|INTERP_WAITING/, 60000); const samples = []; for (let i = 0; i < 4; i += 1) { await settle(1200); samples.push((await axes()).slice(0, 4).map(Number)); } return { 任务模式: (await status()).match(/Task mode (\S+)/)?.[1], 解释器: (await status()).match(/Interpreter (\S+)/)?.[1], 轴位采样: samples }; }); // 11. 暂停并确认位置冻结 await record(11, "暂停程序,确认位置冻结", async () => { await page.locator('[data-axis-control="toolbar-pause-resume"]').click(); await waitStatus(/PAUSED|INTERP_PAUSED/, 30000); const first = await axes(); await settle(2000); const second = await axes(); return { 解释器: (await status()).match(/Interpreter (\S+)/)?.[1], 首次读数: first, "2s后读数": second, 位置冻结: JSON.stringify(first) === JSON.stringify(second) }; }); // 12. 继续并停止 await record(12, "继续运行后停止程序", async () => { await page.locator('[data-axis-control="toolbar-pause-resume"]').click(); await settle(2500); const resumed = (await status()).match(/Interpreter (\S+)/)?.[1]; await page.locator('[data-axis-control="toolbar-stop"]').click(); await settle(3000); return { 继续后解释器: resumed, 停止后解释器: (await status()).match(/Interpreter (\S+)/)?.[1], 反馈: await command() }; }); // 13. DRO 读数 await record(13, "查看 DRO 位置显示并切换单位", async () => { await page.locator('[data-axis-control="tab-dro"]').click(); await page.locator('[data-axis-control="dro-units"]').selectOption("inch"); await settle(1200); const inch = await page.locator(".dro-row").allInnerTexts(); await page.locator('[data-axis-control="dro-units"]').selectOption("mm"); await page.locator('[data-axis-control="dro-coordinate"]').selectOption("relative"); await settle(1200); return { 行数: inch.length, 英寸读数示例: inch[0]?.replace(/\s+/g, " "), 页脚: await text(".dro-footer") }; }); // 14. 切换机型 await record(14, "切换到 XYZBC TRT 机型,轴组随之改变", async () => { await page.locator('[data-axis-control="workspace-case-select"]').selectOption("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"); await settle(2500); await waitCase(); await settle(2500); const bc = await page.locator(".axis-radio span").allInnerTexts(); await page.locator('[data-axis-control="workspace-case-select"]').selectOption("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"); await settle(2500); return { XYZBC轴组: bc, 当前程序: await caseStatus() }; }); // 15. 打开使用说明书 await record(15, "从标题栏按钮打开使用说明书", async () => { const [manual] = await Promise.all([page.waitForEvent("popup"), page.locator('[data-axis-control="user-manual"]').click()]); await manual.waitForLoadState("load", { timeout: 60000 }); await manual.waitForTimeout(1500); const info = { 标题: await manual.title(), 地址: manual.url(), 章节: await manual.locator("section").count(), 插图: await manual.locator("figure img").count() }; await manual.screenshot({ path: resolve(outDir, "step-15-manual.png") }); await manual.close(); return info; }); // 16. 诊断页 await record(16, "查看诊断页的启动资源与门控", async () => { await page.locator('[data-axis-control="tab-diagnostics"]').click(); await settle(1200); const body = await text("#axis-panel-diagnostics"); return { 摘要: body.slice(0, 220) }; }); const report = { report_id: "LINUXCNC-WEB-LOCAL-WALKTHROUGH", url: baseUrl, ran_at: new Date().toISOString(), status: steps.every((step) => step.status === "ok") ? "verified" : "failed", steps, findings, console_errors: consoleErrors, }; await writeFile(resolve(outDir, "walkthrough.report.json"), `${JSON.stringify(report, null, 2)}\n`); console.log(`\n${report.status.toUpperCase()} ${steps.filter((s) => s.status === "ok").length}/${steps.length} 步完成 → ${resolve(outDir, "walkthrough.report.json")}`); if (consoleErrors.length) console.log("页面错误:", consoleErrors.slice(0, 3)); if (findings.length) console.log(`\n发现问题 ${findings.length} 项:`); for (const item of findings) console.log(` ${item.id}|${item.control}|${item.observed}`); if (hold) { console.log(`演练结束,窗口保持 ${Math.round(hold / 1000)} 秒供查看…`); await page.waitForTimeout(hold); } await browser.close(); process.exit(report.status === "verified" ? 0 : 1);