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