251 lines
20 KiB
JavaScript
251 lines
20 KiB
JavaScript
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 = [];
|
|
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: 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("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 acknowledged"), "home_axis_ack");
|
|
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();
|
|
}
|