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({ 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 = []; page.on("pageerror", (error) => errors.push(error.message)); await page.addInitScript(() => { const NativeWorker = window.Worker; window.stepTimes = []; window.workerErrors = []; window.Worker = class extends NativeWorker { pending = new Map(); constructor(...args) { super(...args); this.addEventListener("message", (event) => { const value = event.data, request = this.pending.get(value.seq); if (request?.kind === "SIM_STEP") window.stepTimes.push(performance.now() - request.time); if (value.kind === "ERROR") window.workerErrors.push(value.payload?.gate_reason ?? value.payload?.message_key ?? value.payload?.code); if (request) this.pending.delete(value.seq); }); } postMessage(value, ...args) { this.pending.set(value.seq, { kind: value.kind, time: performance.now() }); super.postMessage(value, ...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 { path: main.dataset.axisProgramPath, loading: main.dataset.axisProgramLoading, status: main.dataset.axisProgramStatus, mode: main.dataset.axisTaskMode, queue: Number(main.dataset.axisTpQueueDepth), line: Number(main.dataset.axisCurrentLine), tick: Number(viewer?.dataset.xyzacTick), epoch: Number(viewer?.dataset.xyzacLivePathEpoch), live: Number(viewer?.dataset.xyzacLiveTraceCount), preview: Number(viewer?.dataset.xyzacToolpathCount), draws: Number(viewer?.dataset.xyzacPreviewDrawCalls), vertices: Number(viewer?.dataset.xyzacPreviewVertices), pose: JSON.parse(viewer?.dataset.xyzacLivePose ?? "{}"), error: main.querySelector(".diagnostic-error")?.textContent ?? null }; }); 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(100); } throw new Error(`${name}: ${JSON.stringify(latest)}`); }; const sampleRun = async () => { await control("toolbar-run").click(); await wait((s) => s.status === "running", "second run"); await page.evaluate(() => { window.stepTimes = []; }); const before = await state(), started = performance.now(); await page.waitForTimeout(5000); const after = await state(), wall = performance.now() - started; const simulated = (after.tick - before.tick) * 10; const latencies = await page.evaluate(() => window.stepTimes); const p95 = latencies.sort((a, b) => a - b)[Math.floor(latencies.length * .95)]; assert.ok(simulated / wall >= .75, `simulation fell behind real time: ${simulated}/${wall}`); assert.ok(p95 < 150, `Worker p95 ${p95} ms`); assert.ok(after.line > before.line && after.live > 0 && after.queue > 0); const pauseStart = performance.now(); await control("toolbar-pause-resume").click(); const paused = await wait((s) => s.status === "paused", "pause"); const pauseMs = performance.now() - pauseStart; await page.waitForTimeout(350); assert.deepEqual((await state()).pose, paused.pose); await control("toolbar-pause-resume").click(); await wait((s) => s.status === "running", "resume"); const stopStart = performance.now(); await control("toolbar-stop").click(); await wait((s) => s.status === "aborted" && s.queue === 0, "stop"); const stopMs = performance.now() - stopStart; assert.ok(pauseMs < 1500 && stopMs < 1500); return { wall_ms: wall, simulated_ms: simulated, ratio: simulated / wall, p95_step_ms: p95, pause_ms: pauseMs, stop_ms: stopMs, line: after.line }; }; try { await page.goto(url, { waitUntil: "domcontentloaded" }); await wait((s) => s.loading === "false" && s.preview > 0, "startup"); await control("toolbar-power").click(); await control("manual-home-all").click(); await control("toolbar-run").click(); process.stderr.write("[second-program] running configured first program to completion\n"); const first = await wait((s) => s.status === "completed" && s.queue === 0 && s.live > 0, "first complete", 180000); // Chips begins at Z10. The configured first program ends at Z10, so its // zero-motion positioning block correctly produces no preview segment. const expectedSegments = Math.abs(first.pose.z - 10) < 1e-9 ? 4683 : 4684; const loadStart = performance.now(); await control("workspace-load-3d-chips").click(); const chips = await wait((s) => s.path === "linuxcnc/nc_files/3D_Chips.ngc" && s.loading === "false" && s.preview === expectedSegments, "chips replacement", 15000); const loadMs = performance.now() - loadStart; assert.equal(chips.epoch, first.epoch); assert.ok(chips.live >= first.live, "native Open must retain live history"); assert.ok(chips.draws <= 2 && chips.vertices === expectedSegments * 2); assert.deepEqual(chips.pose, first.pose, "file replacement changed machine pose"); process.stderr.write(`[second-program] replaced: ${loadMs.toFixed(0)} ms, ${chips.draws} static draw calls, native live trace retained\n`); await control("toolbar-clear-live-plot").click(); const explicitlyCleared = await wait((s) => s.live === 0 && s.epoch === chips.epoch + 1, "native explicit clear"); const execution = await sampleRun(); const stopped = await state(); await control("toolbar-reload").click(); const same = await wait((s) => s.status === "loaded" && s.loading === "false", "same reload"); assert.equal(same.epoch, stopped.epoch, "same source Reload must retain trace"); assert.ok(same.live >= stopped.live); await control("toolbar-clear-live-plot").click(); await wait((s) => s.live === 0 && s.epoch > same.epoch, "explicit clear"); for (const width of [1440, 390]) { await page.setViewportSize({ width, height: width === 390 ? 844 : 1000 }); await control("toolbar-view-p").click(); await page.waitForTimeout(250); const colors = await page.locator("[data-xyzac-preview] canvas").evaluate((canvas) => { const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); const pixels = new Uint8Array(canvas.width * canvas.height * 4); gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); const colors = new Set(); for (let i = 0; i < pixels.length; i += 16) colors.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]); return colors.size; }); assert.ok(colors > 20); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); assert.ok(await page.evaluate(() => document.documentElement.scrollHeight < 5000), "long editor expanded the entire page"); await page.screenshot({ path: `/tmp/axis-chips-replacement-${width}.png`, fullPage: true }); } assert.deepEqual(errors, []); assert.deepEqual(await page.evaluate(() => window.workerErrors), []); console.log(JSON.stringify({ schema: "xyzac-second-program-browser.v1", status: "verified", url, first_live_points: first.live, load_retained_live_points: chips.live, new_epoch: explicitlyCleared.epoch, cleared_live_points: explicitlyCleared.live, load_ms: loadMs, preview_segments: chips.preview, static_draw_calls: chips.draws, execution, browser_errors: errors })); } finally { await browser.close(); }