Files
linuxcncWebWASM/scripts/test_xyzac_pause_resume_browser.mjs

123 lines
7.3 KiB
JavaScript

import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";
assert.ok(process.env.PLAYWRIGHT_MODULE, "PLAYWRIGHT_MODULE is required");
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/";
const browser = await chromium.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--disable-gpu"] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const browserErrors = [], checks = [];
page.on("pageerror", (error) => browserErrors.push(error.message));
await page.addInitScript(() => {
window.axisPauseRequests = []; window.axisPauseResults = [];
const NativeWorker = window.Worker;
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args);
this.addEventListener("message", ({ data }) => window.axisPauseResults.push({ seq: data.seq, kind: data.kind, status: data.payload?.status, error: data.payload?.gate_reason ?? data.payload?.message_key ?? data.payload?.code }));
}
postMessage(data, ...args) {
if (!data.kind.startsWith("UI_STATE_")) window.axisPauseRequests.push({ seq: data.seq, kind: data.kind });
super.postMessage(data, ...args);
}
};
});
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const state = () => page.locator("main.axis-app").evaluate((main) => {
const viewer = main.querySelector("[data-xyzac-preview]");
return { loading: main.dataset.axisProgramLoading, program: main.dataset.axisProgramStatus, path: main.dataset.axisProgramPath,
mode: main.dataset.axisTaskMode, operation: main.dataset.axisStateOperation,
pose: viewer?.dataset.xyzacLivePose, tick: Number(viewer?.dataset.xyzacTick), epoch: viewer?.dataset.xyzacLivePathEpoch,
queue: Number(main.dataset.axisTpQueueDepth), command: main.querySelector(".command-status")?.textContent,
error: main.querySelector(".diagnostic-error")?.textContent };
});
const wait = async (predicate, name, timeout = 30000) => {
const start = performance.now(); let latest;
while (performance.now() - start < timeout) { latest = await state(); if (predicate(latest)) return latest; await page.waitForTimeout(50); }
throw new Error(`${name}: ${JSON.stringify(latest)}`);
};
const action = async (id, kind) => {
const seq = await page.evaluate(() => window.axisPauseRequests.at(-1)?.seq ?? 0);
await control(id).click();
await page.waitForFunction(({ seq, kind }) => window.axisPauseRequests.some((request) => request.seq > seq && request.kind === kind && window.axisPauseResults.some((r) => r.seq === request.seq && r.kind === "COMMAND_RESULT")), { seq, kind });
};
const clearFocus = () => page.evaluate(() => { document.activeElement?.blur(); });
const moveAfterResume = async (paused) => {
await action("toolbar-pause-resume", "PROGRAM_RESUME");
const resumed = await wait((s) => s.operation === "RUNNING" && s.tick >= paused.tick + 10 && s.pose !== paused.pose, "resume must move");
await page.waitForTimeout(250);
assert.ok((await state()).tick > resumed.tick, "sampling stopped after one resume frame");
return resumed;
};
try {
await page.goto(url);
await wait((s) => s.loading === "false" && s.program === "loaded", "startup");
await action("toolbar-power", "POWER"); await action("manual-home-all", "HOME");
for (const program of ["configured", "chips"]) {
if (program === "chips") {
await action("workspace-load-3d-chips", "PROGRAM_LOAD");
await wait((s) => s.path === "linuxcnc/nc_files/3D_Chips.ngc" && s.loading === "false", "chips load");
}
await action("toolbar-run", "PROGRAM_RUN");
await wait((s) => s.operation === "RUNNING" && s.queue > 0, "running");
for (let round = 0; round < 3; round++) {
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
const paused = await wait((s) => s.operation === "PAUSED", "paused");
await action("toolbar-clear-live-plot", "VISUAL_CLEAR");
for (const id of ["override-spindle", "override-max-velocity", "override-feed", "override-jog-speed"]) {
await control(id).focus(); await page.keyboard.press("ArrowLeft"); await page.waitForTimeout(150);
await page.keyboard.press("ArrowRight"); await page.waitForTimeout(150);
}
await page.waitForTimeout(150);
assert.equal((await state()).pose, paused.pose, "a paused control changed position");
assert.ok(await control("toolbar-pause-resume").isEnabled());
await moveAfterResume(paused);
}
await clearFocus();
// A stale/duplicate Resume is a task rejection, not a connection failure.
await page.keyboard.press("s");
await wait((s) => s.command?.includes("PROGRAM_RESUME blocked"), "rejected running resume");
const refused = await state();
await wait((s) => s.tick > refused.tick + 5, "refused command must not stop a running task");
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
const paused = await state();
await clearFocus(); await page.keyboard.press("p");
await wait((s) => s.command?.includes("PROGRAM_PAUSE blocked"), "duplicate pause rejection");
await moveAfterResume(paused);
await action("toolbar-stop", "PROGRAM_ABORT");
await wait((s) => s.operation === "IDLE" && s.queue === 0, "stop after resume");
checks.push(`${program}: repeated pause/clear/overrides/resume + rejection/stop`);
}
await action("toolbar-run", "PROGRAM_RUN");
await wait((s) => s.queue > 0, "step precondition");
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
const beforeStep = await state();
await action("toolbar-step", "PROGRAM_STEP");
await wait((s) => s.pose !== beforeStep.pose, "paused Step moves");
const afterStep = await state();
await moveAfterResume(afterStep);
await action("toolbar-stop", "PROGRAM_ABORT");
checks.push("paused Step then Resume then Stop");
const requests = await page.evaluate(() => window.axisPauseRequests);
requests.forEach((request, index) => assert.equal(request.seq, index + 1));
const errors = await page.evaluate(() => window.axisPauseResults.filter((r) => r.kind === "ERROR"));
assert.equal(errors.length, 4, JSON.stringify(errors));
assert.ok(errors.every((r) => !r.error?.startsWith("protocol.")), JSON.stringify(errors));
assert.deepEqual(browserErrors, []);
await page.screenshot({ path: "/tmp/axis-pause-resume-desktop.png", fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await action("toolbar-run", "PROGRAM_RUN");
await wait((s) => s.queue > 0, "mobile running");
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
const mobilePaused = await state();
await action("toolbar-clear-live-plot", "VISUAL_CLEAR");
await moveAfterResume(mobilePaused);
await action("toolbar-stop", "PROGRAM_ABORT");
await page.screenshot({ path: "/tmp/axis-pause-resume-mobile.png", fullPage: true });
checks.push("mobile pause/clear/resume/stop");
console.log(JSON.stringify({ schema: "xyzac-pause-resume-browser.v1", status: "verified", url, checks, ordered_commands: requests.length, expected_task_rejections: errors.length, browser_errors: browserErrors }));
} catch (error) {
process.stderr.write(JSON.stringify({ state: await state(), recent: await page.evaluate(() => ({ requests: window.axisPauseRequests.slice(-8), results: window.axisPauseResults.slice(-8) })) }) + "\n");
throw error;
} finally { await browser.close(); }