Enforce complete AXIS control inventory

This commit is contained in:
2026-09-07 15:48:49 +08:00
parent 0a679b5eeb
commit bc69c513e3
12 changed files with 261 additions and 54 deletions

View 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" }));

View 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();

View File

@@ -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");