import { pathToFileURL } from "node:url"; const modulePath = process.env.PLAYWRIGHT_MODULE; const url = process.env.XYZAC_PANEL_URL ?? "https://49.232.250.35:58010/"; if (!modulePath) { console.log(JSON.stringify({ schema: "xyzac-panel-buttons-browser.v1", status: "blocked", code: "playwright_unavailable" })); process.exit(0); } const imported = await import(pathToFileURL(modulePath).href); const playwright = imported.default ?? imported; const browser = await playwright.chromium.launch({ headless: process.env.HEADED !== "1", executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--ignore-certificate-errors", "--disable-gpu"] }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true }); const failures = []; const browserErrors = []; const dialogs = []; page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); }); page.on("dialog", async (dialog) => { dialogs.push({ type: dialog.type(), message: dialog.message() }); if (dialog.type() === "prompt" && dialog.message().startsWith("Touch Off:")) await dialog.accept("0"); else if (dialog.type() === "prompt" && dialog.message().startsWith("Coordinate system")) await dialog.accept("P1 G54"); else if (dialog.type() === "confirm" && dialog.message().includes("already homed")) await dialog.accept(); else await dialog.dismiss(); }); const main = page.locator("main.axis-app"); const state = () => main.evaluate((element) => ({ authority: element.dataset.axisAuthority, source: element.dataset.axisStateSource, motion: element.dataset.axisMotionAuthority, task: element.dataset.axisTaskAuthority, machine: `Machine ${element.dataset.axisStateMachine}`, operation: `Operation ${element.dataset.axisStateOperation}`, case: element.querySelector(".case-status")?.textContent?.trim(), mode: element.dataset.axisTaskMode, program: element.dataset.axisProgramStatus, runEnabled: element.dataset.axisRunEnabled, programLoading: element.querySelector(".case-status")?.getAttribute("aria-busy") === "true", homed: element.dataset.axisHomed, line: element.dataset.axisCurrentLine, queue: element.dataset.axisTpQueueDepth, velocity: element.dataset.axisTpVelocity, command: element.querySelector(".command-status")?.textContent?.trim(), error: element.querySelector(".diagnostic-error")?.textContent?.trim() ?? null, })); const wait = async (predicate, label, timeout = 45000) => { const started = Date.now(); let last; while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(250); } throw new Error(`${label}:${JSON.stringify(last)}`); }; const click = async (locator, label, { required = true, timeout = required ? 30000 : 3000 } = {}) => { const started = Date.now(); while (Date.now() - started < timeout) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return true; } await page.waitForTimeout(250); } if (required) throw new Error(`${label}:disabled_or_missing`); return false; }; const record = async (name, action) => { process.stderr.write(`[panel-audit] ${name} ...\n`); try { await action(); process.stderr.write(`[panel-audit] ${name}: verified\n`); return { name, result: "verified" }; } catch (error) { const row = { name, result: "failed", reason: error?.message ?? String(error) }; failures.push(row); process.stderr.write(`[panel-audit] ${name}: failed ${row.reason}\n`); return row; } }; const results = []; try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); results.push(await record("WASM startup", async () => { const value = await wait((s) => s.source === "linuxcnc-wasm" && s.motion === "wasm", "wasm_startup"); if (value.authority !== "linuxcnc-wasm-worker") throw new Error("authority_marker_missing"); })); results.push(await record("OPEN G-CODE FILE", async () => { const fileInput = main.locator('input[type="file"][aria-label="Open G-code file"]'); if (!await fileInput.count()) throw new Error("file_input_missing"); await fileInput.setInputFiles({ name: "browser-panel-audit.ngc", mimeType: "text/plain", buffer: Buffer.from("G21\nG0 X1 Y1 Z1\nM2\n") }); await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "open_file_ack"); await wait((s) => s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "open_file_status"); await click(main.locator('button[aria-label="Load exact XYZAC TRT case"]'), "restore_case_after_open"); await wait((s) => s.program === "loaded" && s.case?.includes("xyzac_switchkins") && s.error === null, "restore_case_after_open_ack"); })); results.push(await record("PACKAGED PROGRAM LOAD", async () => { await click(main.getByRole("button", { name: "Load 3D_Chips.ngc", exact: true }), "load_3d_chips"); await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "load_3d_chips_ack"); await click(main.locator('button[aria-label="Load exact XYZAC TRT case"]'), "restore_case_after_packaged"); await wait((s) => s.program === "loaded" && s.case?.includes("xyzac_switchkins") && s.error === null, "restore_case_after_packaged_ack"); })); results.push(await record("POWER ON", async () => { await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on"); await wait((s) => s.machine === "Machine ON", "power_state"); })); results.push(await record("HOME ALL", async () => { await click(main.locator('button[aria-label="Home all axes"]'), "home_all"); await wait((s) => s.homed === "[true,true,true,true,true]", "home_state"); })); results.push(await record("KINEMATICS IDENTITY/TCP/USERK", async () => { for (const name of ["Identity", "TCP: XYZAC", "User K", "Identity", "TCP: XYZAC", "User K"]) { await click(main.getByRole("button", { name: new RegExp(`^${name}`) }), `kinematics_${name}`); await wait((s) => s.error === null, `kinematics_${name}_ack`); } // LinuxCNC only exposes the manual XYZAC controls for the identity // kinematics in this selected WASM configuration; leave the panel in // that authoritative mode for the following manual-control checks. await click(main.getByRole("button", { name: /^Identity/ }), "kinematics_restore_identity"); await wait((s) => s.error === null, "kinematics_restore_identity_ack"); })); results.push(await record("VIEW buttons", async () => { for (const name of ["Zoom in", "Zoom out", "Top view", "Rotated top view", "Side view", "Front view", "Inverted front view", "Perspective view", "Drag / rotate mode"]) await click(main.locator(`button[aria-label="${name}"]`), `view_${name}`); for (const id of ["preview-panLeft", "preview-panRight", "preview-panUp", "preview-panDown", "preview-reset"]) await click(main.locator(`button[data-axis-control="${id}"]`), id); await main.locator('button[data-axis-control="tab-dro"]').click(); await main.locator('select[data-axis-control="dro-coordinate"]').selectOption("relative"); await main.locator('select[data-axis-control="dro-display"]').selectOption("commanded"); await main.locator('select[data-axis-control="dro-units"]').selectOption("inch"); await main.locator('select[data-axis-control="dro-coordinate"]').selectOption("machine"); await main.locator('select[data-axis-control="dro-display"]').selectOption("actual"); await main.locator('select[data-axis-control="dro-units"]').selectOption("mm"); await main.locator('button[data-axis-control="tab-preview"]').click(); for (const layer of ["path", "live", "limits", "velocity", "offsets", "extents"]) { const checkbox = main.locator(`input[data-axis-control="preview-layer-${layer}"]`); await checkbox.click(); await checkbox.click(); } })); results.push(await record("VISUAL CLEAR", async () => { await click(main.locator('button[aria-label="Clear live plot [Ctrl-K]"]'), "visual_clear"); await wait((s) => s.error === null, "visual_clear_ack"); })); results.push(await record("MANUAL MODE", async () => { await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.mode === "MANUAL" || s.command?.includes("SET_MODE acknowledged"), "manual_mode_ack"); })); results.push(await record("HOME/TOUCH OFF boundaries", async () => { await click(main.locator('button[data-axis-control="manual-home-axis"]'), "home_axis_repeat"); await wait((s) => s.error === null && s.command?.includes("HOME complete"), "home_joint_complete"); await click(main.locator('button[data-axis-control="manual-touch-off"]'), "touch_off"); await wait((s) => s.error === null && s.command?.includes("TOUCH_OFF acknowledged"), "touch_off_ack"); if (!await main.locator('[data-axis-control="manual-tool-touch-off"]').isDisabled()) throw new Error("tool_touch_off_must_be_disabled"); if (!await main.locator('[data-axis-control="manual-override-limits"]').isDisabled()) throw new Error("override_limits_must_be_disabled"); })); results.push(await record("JOG XYZAC", async () => { for (const axis of ["X", "Y", "Z", "A", "C"]) { const axisInput = main.locator(`input[name="axis"][value="${axis.toLowerCase()}"]`); if (!await axisInput.count()) throw new Error(`axis_${axis}:missing`); await axisInput.check(); const jogButton = main.locator(`button[aria-label="Jog ${axis} positive"]`); await jogButton.dispatchEvent("pointerdown", { pointerId: 1, button: 0 }); await wait((s) => s.error === null && s.command?.includes("JOG_START acknowledged"), `jog_${axis}_start_ack`); await page.waitForTimeout(100); await jogButton.dispatchEvent("pointerup", { pointerId: 1, button: 0 }); await wait((s) => s.error === null && s.command?.includes("JOG_STOP acknowledged"), `jog_${axis}_stop_ack`); } })); results.push(await record("SPINDLE", async () => { await click(main.locator('button[aria-label="Spindle clockwise"]'), "spindle_cw"); await wait((s) => s.error === null && s.command?.includes("SPINDLE_COMMAND acknowledged"), "spindle_cw_ack"); for (const id of ["manual-spindle-increase", "manual-spindle-decrease"]) { const adjust = main.locator(`button[data-axis-control="${id}"]`); await adjust.dispatchEvent("pointerdown", { pointerId: 1, button: 0 }); await wait((s) => s.error === null && s.command?.includes("SPINDLE_COMMAND acknowledged"), `${id}_press_ack`); await adjust.dispatchEvent("pointerup", { pointerId: 1, button: 0 }); await wait((s) => s.error === null && s.command?.includes("SPINDLE_CONSTANT acknowledged"), `${id}_release_ack`); } await click(main.locator('button[aria-label="Spindle counter-clockwise"]'), "spindle_ccw"); await click(main.getByRole("button", { name: "Stop spindle", exact: true }), "spindle_stop"); const brake = main.getByLabel("Brake", { exact: true }); if (await brake.count() && !await brake.isDisabled()) { await brake.click(); await wait((s) => s.error === null && s.command?.includes("SPINDLE_BRAKE acknowledged"), "spindle_brake_toggle_ack"); await brake.click(); await wait((s) => s.error === null && s.command?.includes("SPINDLE_BRAKE acknowledged"), "spindle_brake_restore_ack"); } })); results.push(await record("COOLANT", async () => { for (const name of ["Mist", "Flood"]) { const box = main.getByLabel(name, { exact: true }); if (await box.count()) { if (await box.isDisabled()) throw new Error(`${name}:disabled`); await box.click({ force: true }); await wait((s) => s.error === null && s.command?.includes("COOLANT_COMMAND acknowledged"), `${name}_toggle_ack`); await box.click({ force: true }); await wait((s) => s.error === null && s.command?.includes("COOLANT_COMMAND acknowledged"), `${name}_restore_ack`); } } })); results.push(await record("OVERRIDES", async () => { for (const [name, value] of [["Jog speed", "50"], ["Feed override", "50"], ["Spindle override", "50"], ["Max velocity", "50"]]) { const input = main.locator(`input[aria-label="${name}"]`); if (await input.count() && !await input.isDisabled()) { await input.evaluate((element, next) => { element.value = String(next); element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); }, value); await page.waitForTimeout(300); } } })); results.push(await record("MDI", async () => { await main.getByRole("tab", { name: "MDI [F5]" }).click(); await wait((s) => s.mode === "MDI" || s.command?.includes("SET_MODE acknowledged"), "mdi_mode_ack"); const input = main.locator('.mdi-control input'); await input.fill("G0 X1"); await click(main.getByRole("button", { name: "Go", exact: true }), "mdi_go"); await wait((s) => s.error === null && s.command?.includes("MDI_EXECUTE acknowledged") && s.queue === "0", "mdi_ack"); const history = main.locator('button[data-axis-control="mdi-history-select"]').first(); await history.click(); if (await input.inputValue() !== "G0 X1") throw new Error("mdi_history_selection_failed"); await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.mode === "MANUAL" || s.command?.includes("SET_MODE acknowledged"), "manual_mode_ack"); })); results.push(await record("PROGRAM LOAD/RUN/PAUSE/RESUME/ABORT/RESTART/STEP", async () => { await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.queue === "0" && s.error === null, "program_preflight_idle"); await page.waitForTimeout(1000); await click(main.locator('button[data-axis-control="toolbar-reload"]'), "program_load"); await wait((s) => s.program === "loaded" && s.queue === "0" && s.runEnabled === "true" && !s.programLoading && s.error === null, "program_loaded"); await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "program_run"); await wait((s) => s.program === "running", "program_running"); await click(main.locator('button[aria-label="Pause execution [P]"]'), "program_pause"); await wait((s) => s.program === "paused", "program_paused"); await click(main.locator('button[aria-label="Resume execution [S]"]'), "program_resume"); await wait((s) => s.program === "running", "program_resumed"); await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "program_abort"); await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "program_aborted"); await click(main.locator('button[data-axis-control="toolbar-reload"]'), "program_reload_before_step"); await wait((s) => s.program === "loaded" && s.error === null, "program_reload_before_step_ack"); await click(main.locator('button[aria-label="Execute next line [T]"]'), "program_step"); await wait((s) => s.program === "paused", "program_stepped"); await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "program_step_abort"); await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "program_step_abort_ack"); await click(main.locator('button[data-axis-control="toolbar-reload"]'), "program_reload_before_rerun"); await wait((s) => s.program === "loaded" && s.error === null, "program_reload_before_rerun_ack"); await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "program_run_again"); await wait((s) => s.program === "running", "program_running_again"); await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "program_abort_again"); await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program) && s.error === null, "program_abort_again_ack"); })); results.push(await record("EDITOR PROGRAM LOAD + RELOAD", async () => { const editor = main.locator("#axis-program"); await editor.fill("G21\nG1 X2 Y2 Z2 F100\nM2\n"); await click(main.locator('button[data-axis-control="toolbar-reload"]'), "editor_program_load"); await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "editor_program_load_ack"); await click(main.locator('button[aria-label="Reload current G-code [Ctrl-R]"]'), "reload_current_program"); await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "reload_current_program_ack"); })); results.push(await record("DISABLED unsupported buttons", async () => { for (const name of ["Toggle skip lines with slash", "Toggle optional pause"]) if (!await main.locator(`button[aria-label="${name}"]`).isDisabled()) throw new Error(`${name}:must_be_disabled`); })); results.push(await record("MENUS, ABOUT and CASE SELECTION", async () => { await main.locator('[data-axis-control="workspace-case-select"]').selectOption({ index: 0 }); await main.locator('[data-axis-control="menu-file"]').click(); for (const id of ["menu-file-edit-tool-data", "menu-file-quit"]) { const button = main.locator(`[data-axis-control="${id}"]`); if (!await button.isDisabled() || !await button.getAttribute("title")) throw new Error(`${id}:missing_boundary`); } await main.locator('[data-axis-control="menu-help"]').click(); await main.locator('[data-axis-control="menu-help-about"]').click(); await main.locator('[data-axis-control="about-close"]').click(); await main.locator('[data-axis-control="tab-diagnostics"]').click(); await main.locator('[data-axis-control="tab-manual"]').click(); })); results.push(await record("POWER OFF and ESTOP recovery", async () => { await click(main.locator('[data-axis-control="toolbar-power"]'), "power_off"); await wait((s) => s.machine !== "Machine ON", "power_off_ack"); await click(main.locator('[data-axis-control="toolbar-estop"]'), "estop"); await wait((s) => s.machine === "Machine ESTOP", "estop_ack"); if (!await main.locator('[data-axis-control="toolbar-power"]').isDisabled()) throw new Error("power_enabled_during_estop"); await click(main.locator('[data-axis-control="toolbar-estop"]'), "release_estop"); await wait((s) => s.machine !== "Machine ESTOP", "release_estop_ack"); await click(main.locator('[data-axis-control="toolbar-power"]'), "power_restore"); await wait((s) => s.machine === "Machine ON", "power_restore_ack"); })); } finally { const buttons = await main.locator("button").evaluateAll((items) => items.map((button) => ({ text: button.textContent?.trim() ?? "", aria: button.getAttribute("aria-label"), disabled: button.disabled }))); const visibleLabels = buttons.map((button) => button.aria ?? button.text).filter(Boolean); const duplicateLabels = Object.entries(visibleLabels.reduce((counts, label) => { counts[label] = (counts[label] ?? 0) + 1; return counts; }, {})).filter(([, count]) => count > 1); for (const [label, count] of duplicateLabels) if (["Emergency Stop [F1]", "Power on [F2]", "Home all axes", "Run program", "Clear plot", "Stop Jog", "Stop Spindle"].includes(label)) failures.push({ name: "duplicate_visible_control", result: "failed", reason: `${label}:${count}` }); await page.screenshot({ path: "/tmp/xyzac-panel-buttons-browser.png", fullPage: true }); console.log(JSON.stringify({ schema: "xyzac-panel-buttons-browser.v1", status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed", url, results, button_count: buttons.length, disabled_button_count: buttons.filter((button) => button.disabled).length, duplicate_labels: duplicateLabels, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-panel-buttons-browser.png" })); if (failures.length || browserErrors.length) process.exitCode = 1; await browser.close(); }