import assert from "node:assert/strict"; import { pathToFileURL } from "node:url"; const modulePath = process.env.PLAYWRIGHT_MODULE; assert.ok(modulePath, "PLAYWRIGHT_MODULE is required"); const { chromium } = await import(pathToFileURL(modulePath).href); const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5174/"; const browser = await chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--disable-gpu"] }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); const errors = []; const checks = []; let dialogAnswers = []; page.on("pageerror", (error) => errors.push(error.message)); page.on("dialog", (dialog) => dialogAnswers.length ? dialog.accept(dialogAnswers.shift()) : dialog.dismiss()); await page.addInitScript(() => { window.axisMessages = []; window.axisResults = []; const NativeWorker = window.Worker; window.Worker = class extends NativeWorker { constructor(...args) { super(...args); this.addEventListener("message", (event) => { const value = event.data; window.axisResults.push({ kind: value.kind, seq: value.seq, payload: value.payload }); if (window.axisResults.length > 100) window.axisResults.shift(); }); } postMessage(value, ...args) { window.axisMessages.push({ kind: value.kind, seq: value.seq, payload: value.payload }); super.postMessage(value, ...args); } }; }); const control = (id) => page.locator(`[data-axis-control="${id}"]`); const wait = (fn, arg) => page.waitForFunction(fn, arg, { timeout: 45000 }); const ready = () => wait(() => document.querySelector("main")?.dataset.axisProgramLoading === "false"); const queueEmpty = () => wait(() => document.querySelector("main")?.dataset.axisTpQueueDepth === "0"); const messages = (kind) => page.evaluate((kind) => window.axisMessages.filter((entry) => entry.kind === kind), kind); const commandCount = async (kind) => (await messages(kind)).length; const ack = (kind, after = 0) => wait(({ kind, after }) => window.axisResults.some((result) => result.seq > after && result.kind === "COMMAND_RESULT" && window.axisMessages.some((message) => message.seq === result.seq && message.kind === kind)), { kind, after }); const latestSeq = () => page.evaluate(() => window.axisMessages.filter((entry) => !entry.kind.startsWith("UI_STATE_")).at(-1)?.seq ?? 0); const clickAck = async (id, kind) => { const seq = await latestSeq(); await control(id).click(); await ack(kind, seq); }; const pose = () => page.locator('[data-xyzac-preview="toolpath"]').evaluate((node) => JSON.parse(node.dataset.xyzacLivePose)); const check = async (name, run) => { await run(); checks.push(name); process.stderr.write(`[source-flow] ${name}: verified\n`); }; const hold = async (id, release, axis) => { const starts = await commandCount("JOG_START"), stops = await commandCount("JOG_STOP"), seq = await latestSeq(); const before = await pose(); await control(id).dispatchEvent("pointerdown", { pointerId: 1, button: 0 }); await ack("JOG_START", seq); await page.waitForTimeout(400); const during = await pose(); assert.notEqual(during[axis], before[axis], `no ${axis} motion`); assert.equal(await commandCount("JOG_START"), starts + 1, "sampling must not reissue JOG_START"); await release(); await ack("JOG_STOP", seq); await queueEmpty(); const stopped = await pose(); await page.waitForTimeout(180); assert.deepEqual(await pose(), stopped, "motion continued after release"); assert.equal(await commandCount("JOG_STOP"), stops + 1, "release must stop exactly once"); }; const pixels = () => page.locator('[data-xyzac-preview="toolpath"] canvas').evaluate((canvas) => { const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); if (!gl) return { colors: 0 }; const data = new Uint8Array(canvas.width * canvas.height * 4); gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, data); const colors = new Set(); let hash = 2166136261; for (let i = 0; i < data.length; i += 16) { const value = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]; colors.add(value); hash = Math.imul(hash ^ value, 16777619); } return { colors: colors.size, hash, width: canvas.width, height: canvas.height }; }); try { await page.goto(url, { waitUntil: "domcontentloaded" }); await ready(); await check("startup-single-load", async () => assert.equal(await commandCount("PROGRAM_LOAD"), 1)); await clickAck("toolbar-power", "POWER"); await clickAck("manual-home-all", "HOME"); await check("mdi-trim-clear-deduplicate-history", async () => { await control("tab-mdi").click(); const input = control("mdi-command"); const submit = async (text) => { const seq = await latestSeq(); await input.fill(text); await control("mdi-submit").click(); await ack("MDI_EXECUTE", seq); await queueEmpty(); assert.equal(await input.inputValue(), ""); }; await submit(" G21 "); await submit("G21"); assert.equal(await control("mdi-history-select").count(), 1); await submit("G90"); assert.deepEqual(await control("mdi-history-select").allTextContents(), ["G21", "G90"]); assert.equal((await messages("MDI_EXECUTE"))[0].payload.block, "G21"); await input.press("ArrowUp"); assert.equal(await input.inputValue(), "G90"); await input.press("ArrowUp"); assert.equal(await input.inputValue(), "G21"); await input.press("ArrowUp"); assert.equal(await input.inputValue(), ""); await input.press("ArrowDown"); assert.equal(await input.inputValue(), "G21"); const count = await commandCount("MDI_EXECUTE"); await control("mdi-history-select").last().click(); assert.equal(await commandCount("MDI_EXECUTE"), count); const seq = await latestSeq(); await control("mdi-history-select").last().dblclick(); await ack("MDI_EXECUTE", seq); assert.equal(await commandCount("MDI_EXECUTE"), count + 1); assert.equal(await control("mdi-history-select").count(), 2); }); await control("tab-manual").click(); await check("continuous-five-axes-both-directions", async () => { for (const axis of ["x", "y", "z", "a", "c"]) { await control(`manual-axis-${axis}`).check(); for (const direction of ["positive", "negative"]) { const id = `manual-jog-${direction}`; await hold(id, async () => { await control(id).dispatchEvent("pointerup", { pointerId: 1, button: 0 }); await control(id).dispatchEvent("pointercancel", { pointerId: 1, button: 0 }); }, axis); } } }); await control("manual-axis-x").check(); await check("jog-blur-and-hidden-tab-stop", async () => { await hold("manual-jog-positive", () => page.evaluate(() => window.dispatchEvent(new Event("blur"))), "x"); await hold("manual-jog-positive", () => control("tab-diagnostics").click(), "x"); await control("tab-manual").click(); }); await check("jog-fast-release-and-keyboard-hold", async () => { let starts = await commandCount("JOG_START"), stops = await commandCount("JOG_STOP"); await control("manual-jog-positive").evaluate((button) => { button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 9, button: 0 })); button.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 9, button: 0 })); }); await wait((stops) => window.axisMessages.filter((m) => m.kind === "JOG_STOP").length === stops + 1, stops); await queueEmpty(); assert.equal(await commandCount("JOG_START"), starts + 1); await control("manual-jog-positive").focus(); starts = await commandCount("JOG_START"); stops = await commandCount("JOG_STOP"); await page.keyboard.down("Space"); await page.keyboard.down("Space"); await page.waitForTimeout(200); await page.keyboard.up("Space"); await wait((stops) => window.axisMessages.filter((m) => m.kind === "JOG_STOP").length === stops + 1, stops); await queueEmpty(); assert.equal(await commandCount("JOG_START"), starts + 1); }); await check("increment-completes-after-release", async () => { await control("manual-jog-increment").selectOption("0.1000"); const before = await pose(), seq = await latestSeq(), stops = await commandCount("JOG_STOP"); await control("manual-jog-positive").press("Space"); await ack("JOG_START", seq); await queueEmpty(); assert.ok(Math.abs((await pose()).x - before.x - 0.1) < 1e-8); assert.equal(await commandCount("JOG_STOP"), stops); await control("manual-jog-increment").selectOption("Continuous"); }); await check("touch-off-preview-and-relative-coordinate", async () => { const before = await pose(); dialogAnswers = ["2", "P1 G54"]; await clickAck("manual-touch-off", "TOUCH_OFF"); assert.deepEqual(await pose(), before); await control("tab-dro").click(); await control("dro-coordinate").selectOption("relative"); assert.equal(Number(await page.locator(".dro-row").first().locator("output").first().textContent()), 2); await control("dro-coordinate").selectOption("machine"); await control("tab-preview").click(); dialogAnswers = [String(before.x), "P1 G54"]; await clickAck("manual-touch-off", "TOUCH_OFF"); }); await check("spindle-key-toggle-and-release", async () => { await clickAck("manual-spindle-ccw", "SPINDLE_COMMAND"); let seq = await latestSeq(); await page.keyboard.press("F9"); await ack("SPINDLE_COMMAND", seq); assert.equal((await messages("SPINDLE_COMMAND")).at(-1).payload.direction, 0); await clickAck("manual-spindle-cw", "SPINDLE_COMMAND"); seq = await latestSeq(); await page.keyboard.press("F10"); await ack("SPINDLE_COMMAND", seq); assert.equal((await messages("SPINDLE_COMMAND")).at(-1).payload.direction, 0); await clickAck("manual-spindle-cw", "SPINDLE_COMMAND"); seq = await latestSeq(); await control("manual-spindle-increase").dispatchEvent("pointerdown", { pointerId: 2, button: 0 }); await ack("SPINDLE_COMMAND", seq); await page.evaluate(() => window.dispatchEvent(new Event("blur"))); await ack("SPINDLE_CONSTANT", seq); await clickAck("manual-spindle-stop", "SPINDLE_COMMAND"); }); await check("paused-open-gate-and-source-key-case", async () => { await clickAck("toolbar-run", "PROGRAM_RUN"); await clickAck("toolbar-pause-resume", "PROGRAM_PAUSE"); assert.ok(await control("toolbar-open").isDisabled()); assert.ok(await control("toolbar-open").getAttribute("title")); const count = await commandCount("PROGRAM_LOAD"); await page.keyboard.press("Control+r"); await page.waitForTimeout(100); assert.equal(await commandCount("PROGRAM_LOAD"), count); await clickAck("toolbar-stop", "PROGRAM_ABORT"); await wait(() => document.querySelector("main")?.dataset.axisTaskMode === "MANUAL"); const commands = await commandCount("PROGRAM_RUN"); await page.keyboard.press("Control+r"); await ready(); assert.equal(await commandCount("PROGRAM_RUN"), commands, "Ctrl-R must never become Run"); await page.keyboard.press("R"); assert.equal(await commandCount("PROGRAM_RUN"), commands); }); await check("repeat-step-and-restore-tab-mode", async () => { const input = control("program-file-input"); const seq = await latestSeq(); await input.setInputFiles({ name: "source-step.ngc", mimeType: "text/plain", buffer: Buffer.from("G21 G90\nG1 X2 F600\nG1 X3\nM2\n") }); await ack("PROGRAM_LOAD", seq); await ready(); const x = (await pose()).x; await clickAck("toolbar-step", "PROGRAM_STEP"); assert.equal((await pose()).x, x); await clickAck("toolbar-step", "PROGRAM_STEP"); await clickAck("toolbar-step", "PROGRAM_STEP"); await queueEmpty(); await wait(() => Math.abs(JSON.parse(document.querySelector('[data-xyzac-preview="toolpath"]').dataset.xyzacLivePose).x - 2) < 1e-7, "step_first_motion_pose"); assert.ok(Math.abs((await pose()).x - 2) < 1e-7); await clickAck("toolbar-step", "PROGRAM_STEP"); await queueEmpty(); await wait(() => Math.abs(JSON.parse(document.querySelector('[data-xyzac-preview="toolpath"]').dataset.xyzacLivePose).x - 3) < 1e-7, "step_second_motion_pose"); assert.ok(Math.abs((await pose()).x - 3) < 1e-7); await clickAck("toolbar-stop", "PROGRAM_ABORT"); await wait(() => document.querySelector("main")?.dataset.axisTaskMode === "MANUAL"); }); await check("about-modal-and-estop-from-editor", async () => { await control("menu-help").click(); await control("menu-help-about").click(); assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("data-axis-control")), "about-close"); const runs = await commandCount("PROGRAM_RUN"); await page.keyboard.press("r"); assert.equal(await commandCount("PROGRAM_RUN"), runs); await page.keyboard.press("Escape"); assert.equal(await control("about-close").count(), 0); await control("program-editor").focus(); const seq = await latestSeq(); await page.keyboard.press("F1"); await ack("ESTOP", seq); await wait(() => document.querySelector("[data-axis-state-machine]")?.dataset.axisStateMachine === "ESTOP"); assert.ok(await control("toolbar-power").isDisabled()); await clickAck("toolbar-estop", "ESTOP"); await clickAck("toolbar-power", "POWER"); }); await check("desktop-mobile-canvas-and-layout", async () => { for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) { await page.setViewportSize(viewport); await control("tab-preview").click(); await control("toolbar-view-p").click(); await page.waitForTimeout(200); for (const preset of ["z", "z2", "x", "y", "y2", "p"]) { await control(`toolbar-view-${preset}`).click(); await wait((preset) => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacProjection === (preset === "p" ? "perspective" : "orthographic"), preset); } const before = await pixels(); assert.ok(before.colors > 20, JSON.stringify(before)); await control("toolbar-rotate").click(); await page.waitForTimeout(250); const stationary = await pixels(); await page.waitForTimeout(250); assert.equal((await pixels()).hash, stationary.hash, "rotate mode must not animate the camera"); await control("preview-layer-velocity").check(); await wait(() => document.querySelector(".axis-preview-hud")?.textContent.includes("mm/min")); await control("preview-layer-velocity").uncheck(); await control("preview-layer-limits").check(); await wait(() => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacLimitsVisible === "true"); await control("preview-layer-limits").uncheck(); await control("preview-layer-extents").check(); await wait(() => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacExtentsVisible === "true"); await control("preview-layer-extents").uncheck(); await control("toolbar-view-x").click(); await page.waitForTimeout(200); const after = await pixels(); assert.notEqual(after.hash, before.hash, "view did not change canvas"); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, "horizontal page overflow"); assert.equal(await page.locator("img").evaluateAll((images) => images.some((image) => !image.complete || image.naturalWidth === 0)), false); await page.screenshot({ path: `/tmp/axis-source-flow-${viewport.width}.png`, fullPage: true }); } }); assert.deepEqual(errors, []); console.log(JSON.stringify({ schema: "axis-panel-source-flow-browser.v1", status: "verified", url, checks, browser_errors: errors })); } catch (error) { process.stderr.write(JSON.stringify(await page.evaluate(() => ({ messages: window.axisMessages.slice(-12), results: window.axisResults.slice(-3).map((entry) => ({ kind: entry.kind, seq: entry.seq, status: entry.payload?.status, reason: entry.payload?.gate_reason ?? entry.payload?.message_key })) }))) + "\n"); await page.screenshot({ path: "/tmp/axis-source-flow-failure.png", fullPage: true }); throw error; } finally { await browser.close(); }