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 < stopped.liveCount) throw new Error(`open_cleared_native_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('[data-axis-control="toolbar-reload"]'), "reload_same_file"); await wait((value) => value.program === "loaded" && value.loading === "false", "same_file_reloaded"); const reloaded = await state(); if (reloaded.epoch !== completedStable.epoch || reloaded.liveCount < completedStable.liveCount) throw new Error(`reload_cleared_native_live_path:${JSON.stringify(reloaded)}`); await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "rerun_short"); const rerun = await wait((value) => value.program === "running" && value.epoch === completedStable.epoch && value.liveCount >= completedStable.liveCount, "rerun_retains_native_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 < rerun.liveCount || 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 })}`); await load("live-key-clear.ngc", shortSource); await click(main.locator('[data-axis-control="toolbar-run"]'), "run_for_keyboard_clear"); await wait((value) => value.operation === "Operation IDLE" && value.program === "completed" && value.liveCount > 2, "regenerate_live_trace"); const beforeKeyClear = await state(); await page.evaluate(() => document.activeElement?.blur()); await page.keyboard.press("Control+k"); const keyCleared = await wait((value) => value.epoch === beforeKeyClear.epoch + 1 && value.liveCount === 0, "ctrl_k_clears_live_trace"); if (keyCleared.previewCount !== beforeKeyClear.previewCount || keyCleared.queue !== beforeKeyClear.queue || keyCleared.program !== beforeKeyClear.program) throw new Error("ctrl_k_changed_program_or_queue"); 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, reloaded, rerun, replacement, beforeClear, cleared, beforeKeyClear, keyCleared }, canvas_pixels: { desktop: desktopPixels, mobile: mobilePixels }, browser_errors: browserErrors, screenshots: ["/tmp/xyzac-live-path-lifecycle-desktop.png", "/tmp/xyzac-live-path-lifecycle-mobile.png"] })); if (browserErrors.length) process.exitCode = 1; await browser.close();