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: true, 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 = []; page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); }); 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: element.querySelector(".machine-state")?.textContent?.trim(), operation: element.querySelector(".operation-state")?.textContent?.trim(), 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("RESET", async () => { await click(main.locator('button[aria-label="Reset machine"]'), "reset"); await wait((s) => s.command?.includes("RESET acknowledged") || s.error === null, "reset_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}`); })); 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("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(); await click(main.locator(`button[aria-label="Jog ${axis} positive"]`), `jog_${axis}_positive`); await wait((s) => s.error === null, `jog_${axis}_ack`); await click(main.getByRole("button", { name: "Stop", exact: true }).first(), `jog_${axis}_stop`); } })); results.push(await record("SPINDLE", async () => { await click(main.locator('button[aria-label="Spindle clockwise"]'), "spindle_cw"); await click(main.locator('button[aria-label="Spindle counter-clockwise"]'), "spindle_ccw"); await click(main.getByRole("button", { name: "Stop", exact: true }).nth(1), "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("TOOL/PROBE", async () => { for (const name of ["Prepare T1", "Change", "Arm Probe", "Disarm"]) { const clicked = await click(main.getByRole("button", { name, exact: true }), `device_${name}`, { required: false }); if (clicked) await wait((s) => s.error === null, `device_${name}_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"); 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(".program-actions button").first(), "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(".program-actions button").first(), "program_reload_before_step"); await wait((s) => s.program === "loaded" && s.error === null, "program_reload_before_step_ack"); await click(main.locator(".program-actions button").nth(2), "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(".program-actions button").first(), "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("RUN STARTUP SEQUENCE", async () => { await click(main.locator(".program-actions button").first(), "startup_sequence_reload"); await wait((s) => s.program === "loaded" && s.queue === "0" && s.runEnabled === "true" && !s.programLoading && s.error === null, "startup_sequence_reload_ack"); await click(main.getByRole("button", { name: "Run startup sequence", exact: true }), "startup_sequence"); await wait((s) => s.program === "running", "startup_sequence_running"); await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "startup_sequence_abort"); await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program) && s.error === null, "startup_sequence_abort_ack"); })); results.push(await record("EDITOR PROGRAM LOAD + REOPEN", async () => { const editor = main.locator("#axis-program"); await editor.fill("G21\nG1 X2 Y2 Z2 F100\nM2\n"); await click(main.locator(".program-actions button").first(), "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="Reopen editor defaults"]'), "reopen_defaults"); await wait((s) => s.program === "loaded" && s.case?.includes("xyzac_switchkins") && s.error === null, "reopen_defaults_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`); })); } finally { const buttons = await main.locator("button").evaluateAll((items) => items.map((button) => ({ text: button.textContent?.trim() ?? "", aria: button.getAttribute("aria-label"), disabled: button.disabled }))); 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, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-panel-buttons-browser.png" })); await browser.close(); }