Enforce complete AXIS control inventory
This commit is contained in:
35
scripts/test_axis_control_inventory.mjs
Normal file
35
scripts/test_axis_control_inventory.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
const read = (path) => readFile(new URL(path, root), "utf8");
|
||||
const [contract, app, panel, preview] = await Promise.all([
|
||||
read("docs/linuxcnc-axis-web-control-panel-source-logic.md"),
|
||||
read("web/react/src/App.tsx"),
|
||||
read("web/react/src/XyzacTrtPanel.tsx"),
|
||||
read("web/react/src/XyzacToolPreview.tsx"),
|
||||
]);
|
||||
|
||||
const controlIds = [
|
||||
"menu-file", "menu-file-edit-tool-data", "menu-file-quit", "menu-help", "menu-help-about", "about-close",
|
||||
"toolbar-estop", "toolbar-power", "toolbar-open", "toolbar-load-case", "toolbar-reload", "toolbar-run", "toolbar-step", "toolbar-pause-resume", "toolbar-stop", "toolbar-block-delete", "toolbar-optional-pause", "toolbar-zoom-in", "toolbar-zoom-out", "toolbar-view-z", "toolbar-view-z2", "toolbar-view-x", "toolbar-view-y", "toolbar-view-y2", "toolbar-view-p", "toolbar-rotate", "toolbar-clear-live-plot",
|
||||
"workspace-case-select", "workspace-load-3d-chips", "manual-home-all",
|
||||
"switchkins-0", "switchkins-1", "switchkins-2",
|
||||
"tab-manual", "tab-mdi", "tab-diagnostics", "tab-preview", "tab-dro",
|
||||
"manual-axis-x", "manual-axis-y", "manual-axis-z", "manual-axis-a", "manual-axis-c",
|
||||
"manual-jog-increment", "manual-jog-negative", "manual-jog-positive", "manual-home-axis", "manual-touch-off", "manual-tool-touch-off", "manual-override-limits",
|
||||
"manual-spindle-ccw", "manual-spindle-stop", "manual-spindle-cw", "manual-spindle-decrease", "manual-spindle-increase", "manual-spindle-brake", "manual-coolant-mist", "manual-coolant-flood",
|
||||
"mdi-command", "mdi-submit", "mdi-history-select",
|
||||
"preview-panLeft", "preview-panRight", "preview-panUp", "preview-panDown", "preview-reset",
|
||||
"dro-coordinate", "dro-display", "dro-units",
|
||||
"override-jog-speed", "override-feed", "override-spindle", "override-max-velocity", "override-rapid",
|
||||
"preview-layer-path", "preview-layer-live", "preview-layer-limits", "preview-layer-velocity", "preview-layer-offsets", "preview-layer-extents",
|
||||
"program-editor", "program-file-input",
|
||||
];
|
||||
|
||||
for (const id of controlIds) assert.ok(contract.includes(`\`${id}\``), `control missing from source contract: ${id}`);
|
||||
const source = `${app}\n${panel}\n${preview}`;
|
||||
for (const term of ["data-axis-control", "manual-axis-${axis}", "switchkins-${item.type}", "preview-${action}", "preview-layer-${layer}"]) assert.ok(source.includes(term), `control marker missing: ${term}`);
|
||||
for (const forbidden of ["Prepare T1", ">Change</TextButton>", "Arm Probe", ">Disarm</TextButton>", "Reload editor", "program-actions\"><TextButton", '<Menu label="Machine">', '<Menu label="View">']) assert.equal(source.includes(forbidden), false, `non-source or duplicate control rendered: ${forbidden}`);
|
||||
|
||||
console.log(JSON.stringify({ schema: "axis-control-inventory.v1", status: "verified", documented_controls: controlIds.length, duplicate_command_entries_removed: ["machine_menu", "view_menu", "program_pane_load", "tool_probe_commands"], contract: "linuxcnc-axis-web-control-panel-source-logic.md" }));
|
||||
55
scripts/test_axis_control_inventory_browser.mjs
Normal file
55
scripts/test_axis_control_inventory_browser.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
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: "axis-control-inventory-browser.v1", status: "blocked", code: "playwright_unavailable" }));
|
||||
process.exit(0);
|
||||
}
|
||||
const contract = await readFile(new URL("../docs/linuxcnc-axis-web-control-panel-source-logic.md", import.meta.url), "utf8");
|
||||
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 browserErrors = [];
|
||||
page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`));
|
||||
page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); });
|
||||
await page.goto(`${url}${url.includes("?") ? "&" : "?"}control-inventory=1`, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
const main = page.locator("main.axis-app");
|
||||
await main.waitFor({ state: "visible" });
|
||||
for (let index = 0; index < 600; index += 1) {
|
||||
if (await main.getAttribute("data-axis-program-loading") === "false") break;
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
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"]').waitFor({ state: "visible" });
|
||||
const aboutClosePresent = await main.locator('[data-axis-control="about-close"]').count() === 1;
|
||||
await main.locator('[data-axis-control="about-close"]').click();
|
||||
|
||||
const inventory = await main.locator("button,input,select,summary,textarea").evaluateAll((nodes) => nodes
|
||||
.filter((node) => !(node instanceof HTMLTextAreaElement && node.readOnly))
|
||||
.map((node) => ({
|
||||
tag: node.tagName.toLowerCase(),
|
||||
id: node.getAttribute("data-axis-control"),
|
||||
text: (node.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 80),
|
||||
aria: node.getAttribute("aria-label"),
|
||||
disabled: "disabled" in node ? Boolean(node.disabled) : false,
|
||||
hidden: node instanceof HTMLInputElement && node.type === "file",
|
||||
})));
|
||||
const missingIds = inventory.filter((entry) => !entry.id);
|
||||
assert.deepEqual(missingIds, [], `actionable controls without data-axis-control: ${JSON.stringify(missingIds)}`);
|
||||
const ids = inventory.map((entry) => entry.id);
|
||||
for (const id of ids) assert.ok(contract.includes(`\`${id}\``), `runtime control missing from contract: ${id}`);
|
||||
assert.equal(new Set(ids).size, ids.length, `duplicate control IDs: ${JSON.stringify(ids.filter((id, index) => ids.indexOf(id) !== index))}`);
|
||||
for (const removed of ["Prepare T1", "Change", "Arm Probe", "Disarm", "Reload editor"]) assert.equal(inventory.some((entry) => entry.text === removed), false, `removed control still rendered: ${removed}`);
|
||||
assert.equal(await main.locator('[data-axis-control="menu-file-edit-tool-data"]').isDisabled(), true);
|
||||
assert.equal(await main.locator('[data-axis-control="menu-file-quit"]').isDisabled(), true);
|
||||
assert.equal(aboutClosePresent, true);
|
||||
assert.deepEqual(browserErrors, []);
|
||||
|
||||
console.log(JSON.stringify({ schema: "axis-control-inventory-browser.v1", status: "verified", url, actionable_controls: inventory.length + 1, static_control_ids: ids.length, dynamic_controls: ["about-close", "mdi-history-select"], disabled_host_boundaries: ["menu-file-edit-tool-data", "menu-file-quit"], browser_errors: browserErrors }));
|
||||
await browser.close();
|
||||
@@ -13,8 +13,15 @@ const browser = await playwright.chromium.launch({ headless: true, executablePat
|
||||
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 await dialog.dismiss();
|
||||
});
|
||||
const main = page.locator("main.axis-app");
|
||||
const state = () => main.evaluate((element) => ({
|
||||
authority: element.dataset.axisAuthority,
|
||||
@@ -88,21 +95,56 @@ try {
|
||||
}));
|
||||
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 () => {
|
||||
const dialogCount = dialogs.length;
|
||||
await click(main.locator('button[data-axis-control="manual-home-axis"]'), "home_axis_repeat");
|
||||
await page.waitForTimeout(100);
|
||||
if (dialogs.length !== dialogCount + 1 || !dialogs.at(-1)?.message.includes("already homed")) throw new Error("home_axis_confirmation_missing");
|
||||
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();
|
||||
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 jog", exact: true }), `jog_${axis}_stop`);
|
||||
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-decrease", "manual-spindle-increase"]) {
|
||||
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 });
|
||||
@@ -125,12 +167,6 @@ try {
|
||||
}
|
||||
}
|
||||
}));
|
||||
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}"]`);
|
||||
@@ -140,12 +176,12 @@ try {
|
||||
}
|
||||
}
|
||||
}));
|
||||
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("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(".program-actions button").first(), "program_load");
|
||||
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");
|
||||
@@ -155,13 +191,13 @@ try {
|
||||
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 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(".program-actions button").first(), "program_reload_before_rerun");
|
||||
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");
|
||||
@@ -171,7 +207,7 @@ try {
|
||||
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(".program-actions button").first(), "editor_program_load");
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user