import { pathToFileURL } from "node:url"; import { fnv1a64 } from "../web/worker/replay.mjs"; 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-multi-program-load-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 failures = []; const browserErrors = []; const dialogs = []; page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); }); page.on("dialog", async (dialog) => { dialogs.push({ type: dialog.type(), message: dialog.message() }); await dialog.dismiss(); }); const main = page.locator("main.axis-app"); const state = () => main.evaluate((element) => ({ source: element.dataset.axisStateSource, machine: `Machine ${element.dataset.axisStateMachine}`, operation: `Operation ${element.dataset.axisStateOperation}`, program: element.dataset.axisProgramStatus, line: element.dataset.axisCurrentLine, queue: element.dataset.axisTpQueueDepth, velocity: element.dataset.axisTpVelocity, homed: element.dataset.axisHomed, loading: element.querySelector(".case-status")?.getAttribute("aria-busy") === "true", command: element.querySelector(".command-status")?.textContent?.trim(), error: element.querySelector(".diagnostic-error")?.textContent?.trim() ?? null, editor: element.querySelector("#axis-program")?.value ?? "", sourceHash: element.querySelector(".xyzac-program header code")?.textContent?.trim() ?? "", previewCount: element.querySelector('[data-xyzac-preview="toolpath"]')?.getAttribute("data-xyzac-toolpath-count") ?? "0", })); const wait = async (predicate, label, timeout = 60000) => { const started = Date.now(); let last; while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(200); } 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(200); } throw new Error(`${label}:disabled_or_missing`); }; const record = async (name, action) => { try { return { name, result: "verified", ...(await action() ?? {}) }; } catch (error) { const row = { name, result: "failed", reason: error?.message ?? String(error) }; failures.push(row); return row; } }; const absoluteMoves = Array.from({ length: 12 }, (_, index) => { const positive = index % 2 === 0; return `G1 X${positive ? 20 : 0} Y${positive ? 10 : 0} Z${positive ? 5 : 10} A${positive ? 30 : 0} C${positive ? 40 : 0} F600`; }); const incrementalMoves = (x, y, z, a, c) => Array.from({ length: 12 }, (_, index) => { const sign = index % 2 === 0 ? 1 : -1; return `G1 X${sign * x} Y${sign * y} Z${sign * z} A${sign * a} C${sign * c} F600`; }); const programs = [ { name: "browser-a.ngc", source: ["G21", "G90", "G0 X0 Y0 Z10 A0 C0", ...absoluteMoves, "M2", ""].join("\n") }, { name: "browser-b.ngc", source: ["G21", "G91", ...incrementalMoves(1, 0.5, 0, 1, 2), "M2", ""].join("\n") }, { name: "browser-c.ngc", source: ["G21", "G91", ...incrementalMoves(0.5, 1, 0.5, 2, 1), "M2", ""].join("\n") }, ]; const results = []; const evidence = []; try { await page.goto(`${url}?strict-multi-load=20260907`, { waitUntil: "domcontentloaded", timeout: 30000 }); results.push(await record("startup", async () => { await wait((s) => s.source === "linuxcnc-wasm", "startup"); })); results.push(await record("power_home", async () => { await click(main.getByRole("button", { name: "Load 3D_Chips.ngc", exact: true }), "initial_load"); await wait((s) => s.program === "loaded" && !s.loading && s.error === null, "initial_loaded"); await click(main.locator('button[aria-label="Power on [F2]"]'), "power"); await wait((s) => s.machine === "Machine ON", "power_state"); await click(main.locator('button[aria-label="Home all axes"]'), "home"); await wait((s) => s.homed === "[true,true,true,true,true]", "home_state"); })); for (const program of programs) { results.push(await record(`load_${program.name}`, async () => { const input = main.locator('input[type="file"][aria-label="Open G-code file"]'); await input.setInputFiles({ name: program.name, mimeType: "text/plain", buffer: Buffer.from(program.source) }); const loaded = await wait((s) => s.program === "loaded" && !s.loading && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, `${program.name}_loaded`); const hash = fnv1a64(program.source); if (!loaded.editor.includes(program.source.trim())) throw new Error(`${program.name}:editor_source_mismatch`); if (!loaded.sourceHash.includes(hash.slice(0, 12))) throw new Error(`${program.name}:source_hash_mismatch:${loaded.sourceHash}:${hash}`); if (Number(loaded.previewCount) <= 0) throw new Error(`${program.name}:preview_empty`); evidence.push({ name: program.name, source_hash: hash, preview_count: Number(loaded.previewCount), line_after_load: loaded.line }); await click(main.locator('button[aria-label="Begin executing current file [R]"]'), `${program.name}_run`); const running = await wait((s) => ["running", "completed"].includes(s.program) && (Number(s.line) > 0 || Number(s.queue) > 0 || Number(s.velocity) > 0), `${program.name}_running`); await page.waitForTimeout(2000); const active = await state(); if (Number(active.line) <= 0 && Number(active.queue) <= 0 && Number(active.velocity) <= 0) throw new Error(`${program.name}:motion_not_observed:${JSON.stringify(active)}`); let stopped = active; if (active.operation !== "Operation IDLE") { await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), `${program.name}_stop`); stopped = await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted", "completed"].includes(s.program) && s.error === null, `${program.name}_stopped`); } return { source_hash: hash, preview_count: Number(loaded.previewCount), running_line: running.line, stopped_program: stopped.program }; })); } results.push(await record("round_trip_A", async () => { const input = main.locator('input[type="file"][aria-label="Open G-code file"]'); await input.setInputFiles({ name: programs[0].name, mimeType: "text/plain", buffer: Buffer.from(programs[0].source) }); const loaded = await wait((s) => s.program === "loaded" && !s.loading && s.error === null, "roundtrip_loaded"); if (!loaded.sourceHash.includes(evidence[0].source_hash.slice(0, 12))) throw new Error("roundtrip_hash_mismatch"); if (Number(loaded.previewCount) <= 0) throw new Error("roundtrip_preview_empty"); })); } finally { await page.screenshot({ path: "/tmp/xyzac-multi-program-load-browser.png", fullPage: true }); if (dialogs.length) failures.push({ name: "automatic_dialog", result: "failed", reason: JSON.stringify(dialogs) }); console.log(JSON.stringify({ schema: "xyzac-multi-program-load-browser.v1", status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed", url, results, evidence, dialogs, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-multi-program-load-browser.png" })); await browser.close(); }