Align AXIS Web control lifecycle with LinuxCNC

This commit is contained in:
2026-09-07 13:47:10 +08:00
parent 35adac6471
commit 0a679b5eeb
34 changed files with 2598 additions and 177 deletions

View File

@@ -0,0 +1,40 @@
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 [axisPy, axisTcl, taskSource, contract, app, worker, programCore, runtime] = await Promise.all([
read("linuxcnc/src/emc/usr_intf/axis/scripts/axis.py"),
read("linuxcnc/share/axis/tcl/axis.tcl"),
read("linuxcnc/src/emc/task/emctaskmain.cc"),
read("docs/linuxcnc-axis-web-control-panel-source-logic.md"),
read("web/react/src/App.tsx"),
read("web/worker/sim-session.mjs"),
read("wasm-core/src/sim_program.c"),
read("web/worker/wasm-program-runtime.mjs"),
]);
for (const symbol of ["manual_ok", "ensure_mode", "open_file_guts", "task_run", "task_step", "task_pause", "task_resume", "task_stop", "send_mdi_command", "home_all_joints", "home_joint", "touch_off_system", "touch_off_tool", "spindle_forward_toggle", "spindle_backward_toggle", "toggle_override_limits", "cycle_view"]) assert.match(axisPy, new RegExp(`(?:def|class) ${symbol}`), `missing source symbol ${symbol}`);
for (const binding of ["machine_estop", "machine_power", "file_open", "reload", "program_run", "program_step", "program_pause", "program_stop", "program_blockdelete", "program_optpause", "view_zoomin", "view_zoomout", "clear_plot", "jogminus", "jogplus", "zerohome.home", "zerohome.zero", "zerohome.tooltouch", "spindlef.ccw", "spindlef.stop", "spindlef.cw"]) assert.match(axisTcl, new RegExp(`(?:toolbar\\.${binding}|manual\\.jogf|manual\\.spindlef|manual\\.jogf\\.zerohome)`), `missing source binding ${binding}`);
for (const taskTerm of ["EMC_TASK_PLAN_RUN_TYPE", "emcTaskPlanOpen", "emcTaskPlanClose", "programStartLine = run_msg->line"]) assert.match(taskSource, new RegExp(taskTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `missing task source ${taskTerm}`);
for (const term of ["manual_ok", "ensure_mode", "open_file_guts", "EMC_TASK_PLAN_RUN", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_STEP", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "JOG_START", "HOME", "TOUCH_OFF", "Run after completion or Stop", "Toolpath display lifecycle", "live_path_epoch", "positionlogger", "blocked_by_abi_tool_offsets", "Button Dependency Matrix"]) assert.match(contract, new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `contract missing ${term}`);
assert.match(app, /Begin executing current file \[R\]/);
assert.match(app, /Reload current G-code \[Ctrl-R\]/);
assert.match(app, /Rapid Override/);
assert.doesNotMatch(app, /Reset machine/);
assert.doesNotMatch(app, /Run Startup Sequence/);
assert.doesNotMatch(app, /G92\s+\$\{selectedAxis/);
assert.doesNotMatch(app, /cachedProgram/);
assert.doesNotMatch(app, /program:\s*\{\s*path:\s*programPath/);
assert.match(contract, /exactly one PROGRAM_LOAD for the configured OPEN_FILE/);
assert.match(worker, /blocked_by_abi_tool_offsets/);
assert.doesNotMatch(worker, /xyzac_program_aborted_requires_restart/);
assert.match(worker, /reopened_taskfile/);
assert.match(worker, /livePathEpoch/);
assert.doesNotMatch(worker.match(/const visualClearResponse[\s\S]*?switch \(input\.kind\)/)?.[0] ?? "", /previewState\s*=\s*null/);
assert.match(programCore, /SIM_PROGRAM_COMPLETED \|\| program->status == SIM_PROGRAM_ABORTED/);
assert.match(programCore, /sim_program_set_position/);
assert.match(runtime, /synchronizePosition/);
assert.match(worker, /xyzac_motion_active/);
console.log(JSON.stringify({ schema: "axis-control-source-contract.v1", status: "verified", source: "linuxcnc-axis-web-control-panel-source-logic.md", checked: { axis_py_symbols: 17, axis_tcl_bindings: 21, task_source_edges: 4, app_source_controls: 3, worker_abi_boundaries: 1, direct_rerun_edges: 2 } }));

View File

@@ -0,0 +1,122 @@
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-live-path-lifecycle-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 context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
await context.addInitScript(() => { localStorage.clear(); sessionStorage.clear(); });
const page = await context.newPage();
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) => {
const viewer = element.querySelector('[data-xyzac-preview="toolpath"]');
return {
loading: element.dataset.axisProgramLoading,
path: element.dataset.axisProgramPath,
program: element.dataset.axisProgramStatus,
operation: element.querySelector(".operation-state")?.textContent?.trim(),
machine: element.querySelector(".machine-state")?.textContent?.trim(),
homed: element.dataset.axisHomed,
line: element.dataset.axisCurrentLine,
queue: element.dataset.axisTpQueueDepth,
velocity: element.dataset.axisTpVelocity,
epoch: Number(viewer?.getAttribute("data-xyzac-live-path-epoch") ?? -1),
liveCount: Number(viewer?.getAttribute("data-xyzac-live-trace-count") ?? 0),
previewCount: Number(viewer?.getAttribute("data-xyzac-toolpath-count") ?? 0),
error: element.querySelector(".diagnostic-error")?.textContent?.trim() ?? null,
};
});
const wait = async (predicate, label, timeout = 90000) => {
const started = Date.now(); let last;
while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(50); }
throw new Error(`${label}:${JSON.stringify(last)}`);
};
const click = async (locator, label, timeout = 30000) => {
const started = Date.now();
while (Date.now() - started < timeout) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; } await page.waitForTimeout(100); }
throw new Error(`${label}:disabled_or_missing`);
};
const load = async (name, source) => {
const input = main.locator('input[type="file"][aria-label="Open G-code file"]');
await input.setInputFiles({ name, mimeType: "text/plain", buffer: Buffer.from(source) });
return wait((value) => value.loading === "false" && value.path === `browser/${name}` && value.program === "loaded" && value.error === null, `${name}_loaded`);
};
const canvasPixels = async (targetPage) => targetPage.locator('[data-xyzac-preview="toolpath"] canvas').evaluate((canvas) => {
const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true }) ?? canvas.getContext("webgl", { preserveDrawingBuffer: true });
if (!gl) return { width: canvas.width, height: canvas.height, changed: 0 };
const pixels = new Uint8Array(canvas.width * canvas.height * 4);
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let changed = 0;
for (let index = 0; index < pixels.length; index += 4) if (Math.abs(pixels[index] - 5) + Math.abs(pixels[index + 1] - 11) + Math.abs(pixels[index + 2] - 15) > 20) changed += 1;
return { width: canvas.width, height: canvas.height, changed };
});
const longMoves = Array.from({ length: 160 }, (_, index) => `G1 X${index % 2 ? 0 : 6} Y${index % 2 ? 0 : 4} Z${index % 2 ? 10 : 8} A${index % 2 ? 0 : 10} C${index % 2 ? 0 : 15} F300`);
const longSource = ["G21", "G90", ...longMoves, "M2", ""].join("\n");
const shortMoves = Array.from({ length: 12 }, (_, index) => `G1 X${index % 2 ? -0.2 : 0.2} Y${index % 2 ? -0.1 : 0.1} F600`);
const shortSource = ["G21", "G91", ...shortMoves, "M2", ""].join("\n");
const replacementSource = "G21\nG90\nG1 X1 Y1 Z1 F300\nM2\n";
await page.goto(`${url}?live-path-lifecycle=20260907`, { waitUntil: "domcontentloaded", timeout: 30000 });
await wait((value) => value.loading === "false" && value.program === "loaded", "startup");
await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on");
await wait((value) => value.machine === "Machine ON", "power_state");
await click(main.locator('button[aria-label="Home all axes"]'), "home_all");
await wait((value) => value.homed === "[true,true,true,true,true]", "home_state");
const loadedLong = await load("live-long.ngc", longSource);
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_long");
const running = await wait((value) => value.program === "running" && value.liveCount >= 3, "long_live_trace");
await click(main.locator('button[aria-label="Pause execution [P]"]'), "pause");
const paused = await wait((value) => value.program === "paused", "paused");
await page.waitForTimeout(1000);
const pausedStable = await state();
if (paused.epoch !== running.epoch || pausedStable.epoch !== running.epoch || pausedStable.liveCount < running.liveCount || pausedStable.previewCount !== loadedLong.previewCount) throw new Error(`pause_cleared_path:${JSON.stringify({ loadedLong, running, paused, pausedStable })}`);
await click(main.locator('button[aria-label="Resume execution [S]"]'), "resume");
const resumed = await wait((value) => value.program === "running" && value.liveCount >= pausedStable.liveCount, "resumed");
if (resumed.epoch !== running.epoch || resumed.previewCount !== loadedLong.previewCount) throw new Error(`resume_changed_epoch:${JSON.stringify(resumed)}`);
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop");
const stopped = await wait((value) => value.operation === "Operation IDLE" && value.program === "aborted", "stopped");
if (stopped.epoch !== running.epoch || stopped.liveCount < pausedStable.liveCount || stopped.previewCount !== loadedLong.previewCount) throw new Error(`stop_cleared_path:${JSON.stringify(stopped)}`);
const loadedShort = await load("live-short.ngc", shortSource);
if (loadedShort.epoch <= stopped.epoch || loadedShort.liveCount !== 0) throw new Error(`load_did_not_clear_live_path:${JSON.stringify({ stopped, loadedShort })}`);
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_short");
const completed = await wait((value) => value.program === "completed" && value.operation === "Operation IDLE" && value.liveCount >= 2, "short_completed");
await page.waitForTimeout(1000);
const completedStable = await state();
if (completedStable.epoch !== completed.epoch || completedStable.liveCount < completed.liveCount || completedStable.previewCount !== loadedShort.previewCount) throw new Error(`completion_cleared_path:${JSON.stringify({ completed, completedStable })}`);
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "rerun_short");
const rerun = await wait((value) => value.epoch > completedStable.epoch && value.liveCount < completedStable.liveCount, "rerun_new_epoch");
await wait((value) => value.program === "completed" && value.operation === "Operation IDLE", "rerun_completed");
const replacement = await load("live-replacement.ngc", replacementSource);
if (replacement.epoch <= rerun.epoch || replacement.liveCount !== 0 || replacement.previewCount !== 1) throw new Error(`replacement_lifecycle_invalid:${JSON.stringify({ rerun, replacement })}`);
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_replacement");
await wait((value) => value.liveCount >= 1 && Number(value.line) > 0, "replacement_trace");
await wait((value) => value.operation === "Operation IDLE", "replacement_idle");
const beforeClear = await state();
await click(main.locator('button[aria-label="Clear live plot [Ctrl-K]"]'), "clear_live_plot");
const cleared = await wait((value) => value.epoch > beforeClear.epoch && value.liveCount <= 1, "cleared_live_plot");
if (cleared.previewCount !== beforeClear.previewCount) throw new Error(`clear_removed_static_preview:${JSON.stringify({ beforeClear, cleared })}`);
const desktopPixels = await canvasPixels(page);
if (desktopPixels.changed < 100) throw new Error(`desktop_canvas_blank:${JSON.stringify(desktopPixels)}`);
await page.screenshot({ path: "/tmp/xyzac-live-path-lifecycle-desktop.png", fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(500);
const mobilePixels = await canvasPixels(page);
if (mobilePixels.changed < 100) throw new Error(`mobile_canvas_blank:${JSON.stringify(mobilePixels)}`);
await page.screenshot({ path: "/tmp/xyzac-live-path-lifecycle-mobile.png", fullPage: true });
console.log(JSON.stringify({ schema: "xyzac-live-path-lifecycle-browser.v1", status: browserErrors.length ? "failed" : "verified", lifecycle: { loadedLong, running, paused: pausedStable, resumed, stopped, loadedShort, completed: completedStable, rerun, replacement, beforeClear, cleared }, canvas_pixels: { desktop: desktopPixels, mobile: mobilePixels }, browser_errors: browserErrors, screenshots: ["/tmp/xyzac-live-path-lifecycle-desktop.png", "/tmp/xyzac-live-path-lifecycle-mobile.png"] }));
await browser.close();

View File

@@ -0,0 +1,112 @@
import { pathToFileURL } from "node:url";
import { fnv1a64 } from "../web/worker/replay.mjs";
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-multi-program-load-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 context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
await context.addInitScript(() => { localStorage.clear(); sessionStorage.clear(); });
const page = await context.newPage();
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() }); await dialog.dismiss(); });
const main = page.locator("main.axis-app");
const state = () => main.evaluate((element) => ({
source: element.dataset.axisStateSource,
machine: element.querySelector(".machine-state")?.textContent?.trim(),
operation: element.querySelector(".operation-state")?.textContent?.trim(),
program: element.dataset.axisProgramStatus,
line: element.dataset.axisCurrentLine,
queue: element.dataset.axisTpQueueDepth,
velocity: element.dataset.axisTpVelocity,
homed: element.dataset.axisHomed,
loading: element.querySelector(".case-status")?.getAttribute("aria-busy") === "true",
command: element.querySelector(".command-status")?.textContent?.trim(),
error: element.querySelector(".diagnostic-error")?.textContent?.trim() ?? null,
editor: element.querySelector("#axis-program")?.value ?? "",
sourceHash: element.querySelector(".xyzac-program header code")?.textContent?.trim() ?? "",
previewCount: element.querySelector('[data-xyzac-preview="toolpath"]')?.getAttribute("data-xyzac-toolpath-count") ?? "0",
}));
const wait = async (predicate, label, timeout = 60000) => {
const started = Date.now(); let last;
while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(200); }
throw new Error(`${label}:${JSON.stringify(last)}`);
};
const click = async (locator, label, timeout = 30000) => {
const started = Date.now();
while (Date.now() - started < timeout) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; } await page.waitForTimeout(200); }
throw new Error(`${label}:disabled_or_missing`);
};
const record = async (name, action) => { try { return { name, result: "verified", ...(await action() ?? {}) }; } catch (error) { const row = { name, result: "failed", reason: error?.message ?? String(error) }; failures.push(row); return row; } };
const absoluteMoves = Array.from({ length: 12 }, (_, index) => {
const positive = index % 2 === 0;
return `G1 X${positive ? 20 : 0} Y${positive ? 10 : 0} Z${positive ? 5 : 10} A${positive ? 30 : 0} C${positive ? 40 : 0} F600`;
});
const incrementalMoves = (x, y, z, a, c) => Array.from({ length: 12 }, (_, index) => {
const sign = index % 2 === 0 ? 1 : -1;
return `G1 X${sign * x} Y${sign * y} Z${sign * z} A${sign * a} C${sign * c} F600`;
});
const programs = [
{ name: "browser-a.ngc", source: ["G21", "G90", "G0 X0 Y0 Z10 A0 C0", ...absoluteMoves, "M2", ""].join("\n") },
{ name: "browser-b.ngc", source: ["G21", "G91", ...incrementalMoves(1, 0.5, 0, 1, 2), "M2", ""].join("\n") },
{ name: "browser-c.ngc", source: ["G21", "G91", ...incrementalMoves(0.5, 1, 0.5, 2, 1), "M2", ""].join("\n") },
];
const results = [];
const evidence = [];
try {
await page.goto(`${url}?strict-multi-load=20260907`, { waitUntil: "domcontentloaded", timeout: 30000 });
results.push(await record("startup", async () => { await wait((s) => s.source === "linuxcnc-wasm", "startup"); }));
results.push(await record("power_home", async () => {
await click(main.getByRole("button", { name: "Load 3D_Chips.ngc", exact: true }), "initial_load");
await wait((s) => s.program === "loaded" && !s.loading && s.error === null, "initial_loaded");
await click(main.locator('button[aria-label="Power on [F2]"]'), "power");
await wait((s) => s.machine === "Machine ON", "power_state");
await click(main.locator('button[aria-label="Home all axes"]'), "home");
await wait((s) => s.homed === "[true,true,true,true,true]", "home_state");
}));
for (const program of programs) {
results.push(await record(`load_${program.name}`, async () => {
const input = main.locator('input[type="file"][aria-label="Open G-code file"]');
await input.setInputFiles({ name: program.name, mimeType: "text/plain", buffer: Buffer.from(program.source) });
const loaded = await wait((s) => s.program === "loaded" && !s.loading && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, `${program.name}_loaded`);
const hash = fnv1a64(program.source);
if (!loaded.editor.includes(program.source.trim())) throw new Error(`${program.name}:editor_source_mismatch`);
if (!loaded.sourceHash.includes(hash.slice(0, 12))) throw new Error(`${program.name}:source_hash_mismatch:${loaded.sourceHash}:${hash}`);
if (Number(loaded.previewCount) <= 0) throw new Error(`${program.name}:preview_empty`);
evidence.push({ name: program.name, source_hash: hash, preview_count: Number(loaded.previewCount), line_after_load: loaded.line });
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), `${program.name}_run`);
const running = await wait((s) => ["running", "completed"].includes(s.program) && (Number(s.line) > 0 || Number(s.queue) > 0 || Number(s.velocity) > 0), `${program.name}_running`);
await page.waitForTimeout(2000);
const active = await state();
if (Number(active.line) <= 0 && Number(active.queue) <= 0 && Number(active.velocity) <= 0) throw new Error(`${program.name}:motion_not_observed:${JSON.stringify(active)}`);
let stopped = active;
if (active.operation !== "Operation IDLE") {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), `${program.name}_stop`);
stopped = await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted", "completed"].includes(s.program) && s.error === null, `${program.name}_stopped`);
}
return { source_hash: hash, preview_count: Number(loaded.previewCount), running_line: running.line, stopped_program: stopped.program };
}));
}
results.push(await record("round_trip_A", async () => {
const input = main.locator('input[type="file"][aria-label="Open G-code file"]');
await input.setInputFiles({ name: programs[0].name, mimeType: "text/plain", buffer: Buffer.from(programs[0].source) });
const loaded = await wait((s) => s.program === "loaded" && !s.loading && s.error === null, "roundtrip_loaded");
if (!loaded.sourceHash.includes(evidence[0].source_hash.slice(0, 12))) throw new Error("roundtrip_hash_mismatch");
if (Number(loaded.previewCount) <= 0) throw new Error("roundtrip_preview_empty");
}));
} finally {
await page.screenshot({ path: "/tmp/xyzac-multi-program-load-browser.png", fullPage: true });
if (dialogs.length) failures.push({ name: "automatic_dialog", result: "failed", reason: JSON.stringify(dialogs) });
console.log(JSON.stringify({ schema: "xyzac-multi-program-load-browser.v1", status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed", url, results, evidence, dialogs, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-multi-program-load-browser.png" }));
await browser.close();
}

View File

@@ -75,7 +75,6 @@ try {
}));
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}`);
@@ -99,13 +98,13 @@ try {
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`);
await click(main.getByRole("button", { name: "Stop jog", exact: true }), `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");
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();
@@ -158,7 +157,7 @@ try {
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 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");
@@ -169,28 +168,23 @@ try {
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 () => {
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 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");
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`);
}));
} 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, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-panel-buttons-browser.png" }));
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" }));
await browser.close();
}

View File

@@ -0,0 +1,143 @@
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-program-execution-browser-strict.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 context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
await context.addInitScript(() => { localStorage.clear(); sessionStorage.clear(); });
const page = await context.newPage();
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()}`); });
page.on("dialog", async (dialog) => { await dialog.accept(); });
const main = page.locator("main.axis-app");
const state = () => main.evaluate((element) => ({
source: element.dataset.axisStateSource,
authority: element.dataset.axisAuthority,
motion: element.dataset.axisMotionAuthority,
machine: element.querySelector(".machine-state")?.textContent?.trim(),
operation: element.querySelector(".operation-state")?.textContent?.trim(),
mode: element.dataset.axisTaskMode,
program: element.dataset.axisProgramStatus,
runEnabled: element.dataset.axisRunEnabled,
loading: 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 = 60000) => {
const started = Date.now(); let last;
while (Date.now() - started < timeout) {
last = await state();
if (predicate(last)) return last;
await page.waitForTimeout(200);
}
throw new Error(`${label}:${JSON.stringify(last)}`);
};
const click = async (locator, label, timeout = 30000) => {
const started = Date.now();
while (Date.now() - started < timeout) {
if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; }
await page.waitForTimeout(200);
}
throw new Error(`${label}:disabled_or_missing`);
};
const record = async (name, action) => {
try { const result = await action(); return { name, result: "verified", ...(result ?? {}) }; }
catch (error) { const row = { name, result: "failed", reason: error?.message ?? String(error) }; failures.push(row); return row; }
};
const samples = [];
const runAndObserve = async (name, waitMs = 5000) => record(name, async () => {
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), `${name}_run`);
const started = await wait((s) => s.program === "running" && s.operation === "Operation RUNNING", `${name}_running`);
await page.waitForTimeout(waitMs);
const observed = await state();
samples.push({ name, started, observed });
if (observed.error !== null) throw new Error(`${name}_error:${observed.error}`);
if (Number(observed.line) <= 0 && Number(observed.queue) <= 0 && Number(observed.velocity) <= 0) throw new Error(`${name}_no_motion:${JSON.stringify(observed)}`);
return { observed };
});
const results = [];
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
results.push(await record("startup", async () => {
const s = await wait((v) => v.source === "linuxcnc-wasm" && v.motion === "wasm", "wasm_startup");
if (s.authority !== "linuxcnc-wasm-worker") throw new Error("authority_marker_missing");
}));
results.push(await record("initial_program_load", async () => {
await click(main.getByRole("button", { name: "Load 3D_Chips.ngc", exact: true }), "load_initial");
return { state: await wait((s) => s.program === "loaded" && !s.loading && s.error === null, "initial_program_loaded") };
}));
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 runAndObserve("first_run", 5000));
results.push(await record("stop_first_run", async () => {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop_first_run");
return { state: await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "first_stop_idle") };
}));
results.push(await runAndObserve("rerun_after_abort_without_reload", 3000));
results.push(await record("stop_direct_rerun", async () => {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop_direct_rerun");
await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "direct_rerun_stop_idle");
}));
results.push(await record("reload_after_stop", async () => {
await click(main.getByRole("button", { name: "Load 3D_Chips.ngc", exact: true }), "reload_after_stop");
return { state: await wait((s) => s.program === "loaded" && s.queue === "0" && !s.loading && s.error === null, "reload_after_stop_loaded") };
}));
results.push(await runAndObserve("rerun_after_reload", 3000));
results.push(await record("stop_second_run", async () => {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop_second_run");
await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "second_stop_idle");
}));
results.push(await record("load_second_gcode", async () => {
const moves = Array.from({ length: 160 }, (_, index) => {
const direction = index % 2 === 0 ? 1 : 0;
return `G1 X${direction ? 20 : 0} Y${direction ? 10 : 0} Z${direction ? 5 : 10} A${direction ? 30 : 0} C${direction ? 40 : 0} F100`;
});
const source = ["G21", "G90", "G0 X0 Y0 Z10 A0 C0", ...moves, "M2", ""].join("\n");
const fileInput = main.locator('input[type="file"][aria-label="Open G-code file"]');
await fileInput.setInputFiles({ name: "strict-second-load.ngc", mimeType: "text/plain", buffer: Buffer.from(source) });
return { state: await wait((s) => s.program === "loaded" && !s.loading && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "second_gcode_loaded") };
}));
results.push(await runAndObserve("run_second_gcode", 3000));
results.push(await record("stop_second_gcode", async () => {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop_second_gcode");
await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted", "completed"].includes(s.program), "second_gcode_stop_idle");
}));
} finally {
await page.screenshot({ path: "/tmp/xyzac-program-execution-browser-strict.png", fullPage: true });
console.log(JSON.stringify({
schema: "xyzac-program-execution-browser-strict.v1",
status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed",
url,
results,
samples,
failures,
browser_errors: browserErrors,
final_state: await state(),
screenshot: "/tmp/xyzac-program-execution-browser-strict.png",
}));
await browser.close();
}

View File

@@ -0,0 +1,99 @@
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { fnv1a64 } from "../web/worker/replay.mjs";
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-refresh-single-program-browser.v1", status: "blocked", code: "playwright_unavailable" }));
process.exit(0);
}
const defaultPath = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc";
const legacyPath = "linuxcnc/nc_files/3D_Chips.ngc";
const defaultSource = await readFile(new URL(`../${defaultPath}`, import.meta.url), "utf8");
const legacySource = await readFile(new URL(`../${legacyPath}`, import.meta.url), "utf8");
const defaultHash = fnv1a64(defaultSource.replace(/\r\n/g, "\n").replace(/\r/g, "\n"));
const legacyHash = fnv1a64(legacySource.replace(/\r\n/g, "\n").replace(/\r/g, "\n"));
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 context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
const page = await context.newPage();
const browserErrors = [];
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) => { await dialog.accept(); });
const main = page.locator("main.axis-app");
const state = () => main.evaluate((element) => ({
loading: element.dataset.axisProgramLoading,
path: element.dataset.axisProgramPath,
sourceHash: element.dataset.axisProgramSourceHash,
program: element.dataset.axisProgramStatus,
runEnabled: element.dataset.axisRunEnabled,
machine: element.querySelector(".machine-state")?.textContent?.trim(),
operation: element.querySelector(".operation-state")?.textContent?.trim(),
homed: element.dataset.axisHomed,
line: element.dataset.axisCurrentLine,
queue: element.dataset.axisTpQueueDepth,
velocity: element.dataset.axisTpVelocity,
case: element.querySelector(".case-status")?.textContent?.trim(),
previewCount: element.querySelector('[data-xyzac-preview="toolpath"]')?.getAttribute("data-xyzac-toolpath-count") ?? "0",
powerDisabled: element.querySelector('button[aria-label="Power on [F2]"]')?.disabled,
runDisabled: element.querySelector('button[aria-label="Begin executing current file [R]"]')?.disabled,
error: element.querySelector(".diagnostic-error")?.textContent?.trim() ?? null,
}));
const wait = async (predicate, label, timeout = 60000) => {
const started = Date.now(); let last;
while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(200); }
throw new Error(`${label}:${JSON.stringify(last)}`);
};
const click = async (locator, label, timeout = 30000) => {
const started = Date.now();
while (Date.now() - started < timeout) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; } await page.waitForTimeout(200); }
throw new Error(`${label}:disabled_or_missing`);
};
await page.goto(`${url}?refresh-single-program-seed=20260907`, { waitUntil: "domcontentloaded", timeout: 30000 });
await wait((s) => s.loading === "false" && s.path === defaultPath && s.sourceHash === defaultHash, "initial_default_program");
const legacyState = { version: 1, selectedAxis: "x", leftTab: "manual", rightTab: "preview", view: "p", zoom: 1, rotateMode: false, coordType: "machine", displayType: "actual", metric: true, layers: { grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false }, casePath: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", touchOffSystem: "P1 G54", program: { path: legacyPath, source: legacySource } };
const savedAt = Date.now();
const legacyRecordUnsigned = { schema: "linuxcnc-ui-temp-json.v1", context_id: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", key: "xyzac-trt", generation: 1000, saved_at_ms: savedAt, state: legacyState };
const legacyRecord = { ...legacyRecordUnsigned, state_hash: fnv1a64(legacyState), record_hash: fnv1a64(legacyRecordUnsigned) };
await page.evaluate(async (record) => {
const root = await navigator.storage.getDirectory();
const app = await root.getDirectoryHandle("linuxcnc-web-wasm", { create: true });
const states = await app.getDirectoryHandle("ui-state", { create: true });
const project = await states.getDirectoryHandle("xyzac-trt", { create: true });
for (const name of ["state-a.json", "state-b.json", "manifest.json"]) { try { await project.removeEntry(name); } catch { /* empty cache */ } }
const handle = await project.getFileHandle("state-a.json", { create: true });
const writable = await handle.createWritable();
await writable.write(JSON.stringify(record));
await writable.close();
}, legacyRecord);
await page.reload({ waitUntil: "domcontentloaded", timeout: 30000 });
const transitions = [];
for (let index = 0; index < 140; index += 1) {
const sample = await state();
transitions.push(sample);
if (sample.path === legacyPath || sample.sourceHash === legacyHash || sample.case?.includes("3D_Chips.ngc")) throw new Error(`legacy_program_auto_loaded:${JSON.stringify(sample)}`);
if (sample.loading === "true" && (sample.powerDisabled !== true || sample.runDisabled !== true || sample.previewCount !== "0")) throw new Error(`startup_loading_gate_invalid:${JSON.stringify(sample)}`);
await page.waitForTimeout(250);
}
const stable = await state();
if (stable.loading !== "false" || stable.path !== defaultPath || stable.sourceHash !== defaultHash || stable.previewCount !== "29" || stable.error !== null) throw new Error(`configured_program_not_stable:${JSON.stringify(stable)}`);
await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on");
await wait((s) => s.machine === "Machine ON", "power_on_state");
await click(main.locator('button[aria-label="Home all axes"]'), "home_all");
await wait((s) => s.homed === "[true,true,true,true,true]", "home_state");
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run");
const running = await wait((s) => s.program === "running" && s.operation === "Operation RUNNING", "program_running");
await page.waitForTimeout(3000);
const moving = await state();
if (Number(moving.line) <= 0 && Number(moving.queue) <= 0 && Number(moving.velocity) <= 0) throw new Error(`program_not_moving:${JSON.stringify(moving)}`);
await page.screenshot({ path: "/tmp/xyzac-refresh-single-program-browser.png", fullPage: true });
console.log(JSON.stringify({ schema: "xyzac-refresh-single-program-browser.v1", status: browserErrors.length ? "failed" : "verified", configured_program: { path: defaultPath, source_hash: defaultHash, preview_count: Number(stable.previewCount) }, ignored_legacy_program: { path: legacyPath, source_hash: legacyHash }, sampled_ms: transitions.length * 250, running, moving, browser_errors: browserErrors, screenshot: "/tmp/xyzac-refresh-single-program-browser.png" }));
await browser.close();