import { mkdir, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { createReadStream, statSync } from "node:fs"; import { chromium } from "../app/node_modules/playwright/index.mjs"; const repoRoot = resolve(import.meta.dirname, "../.."); const projectRoot = resolve(repoRoot, "web-rtcp-5axis-xyzbc-trt-sim-plan"); const appUrlPath = "/web-rtcp-5axis-xyzbc-trt-sim-plan/app/index.html"; const sourceRel = "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc"; const samplePeriodMs = 50; const chromiumExecutable = process.env.CHROMIUM || findSystemChromium(); const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); const outputDir = resolve( projectRoot, "working/screenshots", `web-simulation-gcode-full-50ms-${timestamp}`, ); const maxFramesArg = Number(process.env.MAX_FRAMES || process.argv.find((arg) => arg.startsWith("--max-frames="))?.split("=")[1] || 0); const maxFrames = Number.isFinite(maxFramesArg) && maxFramesArg > 0 ? Math.floor(maxFramesArg) : null; await mkdir(outputDir, { recursive: true }); const server = await startStaticServer(repoRoot); let browser; try { browser = await chromium.launch({ headless: true, executablePath: chromiumExecutable || undefined, args: ["--disable-gpu", "--no-sandbox"], }); const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1, }); const page = await context.newPage(); page.setDefaultTimeout(30000); await page.goto(`http://127.0.0.1:${server.port}${appUrlPath}`, { waitUntil: "networkidle" }); await page.waitForFunction(() => Boolean(window.webRtcp5AxisSimulation?.getState)); await Promise.all([ page.evaluate(() => window.webRtcp5AxisSimulation.machineFileSeedReady), page.evaluate(() => window.webRtcp5AxisSimulation.interpreterRuntimeReady), ]); await page.evaluate((selectedSourceRel) => { const api = window.webRtcp5AxisSimulation; api.dispatch({ type: "TASK_HAL_RUNTIME_FAILED", error: "disabled for deterministic 50ms screenshot playback; task/HAL path is covered by browser smoke", }); api.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel: selectedSourceRel }); }, sourceRel); await waitForState(page, (state) => ( state.machineFileStaging?.selectedGcodeSourceRel === sourceRel && Number(state.programAxisPreviewPath?.sampleCount || 0) > 0 && state.programAxisPreviewPath?.samplePeriodMs === samplePeriodMs ), "real G-code sample stream loaded"); await page.evaluate(() => window.webRtcp5AxisSimulation.dispatch({ type: "RUN_READY" })); await waitForState(page, (state) => ( state.machine?.powerOn === true && state.machine?.allHomed === true && state.machine?.mode === "auto" && state.rtcpState === "on" ), "RUN_READY state"); const initial = await page.evaluate(() => window.webRtcp5AxisSimulation.getState()); const sampleCount = Number(initial.programAxisPreviewPath?.sampleCount || 0); const frameCount = maxFrames ? Math.min(maxFrames, sampleCount) : sampleCount; const frames = []; await page.waitForSelector("[data-five-axis-canvas]"); await page.waitForTimeout(100); for (let sampleIndex = 0; sampleIndex < frameCount; sampleIndex += 1) { await page.evaluate((targetSampleIndex) => { const api = window.webRtcp5AxisSimulation; const state = api.getState(); state.programExecutionSampleIndex = targetSampleIndex - 1; state.machine.interpState = "idle"; state.machine.interpResumeState = "idle"; state.runState = "idle"; api.dispatch({ type: "STEP" }); }, sampleIndex); await waitForState(page, (state, sourceRel, samplePeriodMs, context) => ( Number(state.programExecutionSampleIndex || 0) === context.sampleIndex && state.programUiExecution?.source === "programAxisPreviewPath.samples" ), `sample ${sampleIndex}`, 30000, { sampleIndex }); await page.waitForTimeout(16); const live = await page.evaluate(() => { const state = window.webRtcp5AxisSimulation.getState(); const programPane = document.querySelector('[data-region="program"]'); const monitor = document.querySelector("[data-linuxcnc-process-monitor]"); const canvas = document.querySelector("[data-five-axis-canvas]"); return { runState: state.runState, activeLine: state.activeLine, programExecutionSampleIndex: state.programExecutionSampleIndex, programUiExecution: state.programUiExecution, programPaneDataset: { ...(programPane?.dataset || {}) }, monitorDataset: { ...(monitor?.dataset || {}) }, canvasDataset: { threeToolAxis: canvas?.dataset?.threeToolAxis || null, threeToolGlyphAxis: canvas?.dataset?.threeToolGlyphAxis || null, }, axisPose: state.axisPose, toolAxisVector: state.toolAxisVector, }; }); const frameName = `frame-${String(sampleIndex).padStart(4, "0")}-t${String(sampleIndex * samplePeriodMs).padStart(6, "0")}ms.png`; await page.screenshot({ path: resolve(outputDir, frameName), fullPage: true }); frames.push({ index: sampleIndex, timeMs: sampleIndex * samplePeriodMs, file: frameName, sourceFile: live.programUiExecution?.sourceFile || null, line: live.programUiExecution?.line || null, statement: live.programUiExecution?.statement || "", operation: live.programUiExecution?.operation || null, gcodeStepIndex: live.programUiExecution?.gcodeStepIndex ?? null, segmentIndex: live.programUiExecution?.segmentIndex ?? null, motionType: live.programUiExecution?.motionType || null, activeKinematics: live.programUiExecution?.activeKinematics || null, joint: live.programUiExecution?.joint || null, tcp: live.programUiExecution?.tcp || null, toolAxis: live.programUiExecution?.toolAxis || null, axisPose: live.axisPose, toolAxisVector: live.toolAxisVector, canvasDataset: live.canvasDataset, }); } const finalState = await page.evaluate(() => { const state = window.webRtcp5AxisSimulation.getState(); return { activeProgram: state.activeProgram, selectedGcodeSourceRel: state.machineFileStaging?.selectedGcodeSourceRel || null, source: state.programAxisPreviewPath?.source || null, semanticBoundary: state.programAxisPreviewPath?.semanticBoundary || null, samplePeriodMs: state.programAxisPreviewPath?.samplePeriodMs || null, sampleCount: state.programAxisPreviewPath?.sampleCount || state.programAxisPreviewPath?.samples?.length || 0, capturedSampleIndex: state.programExecutionSampleIndex, runState: state.runState, programUiExecution: state.programUiExecution, gcodeExecutionProcess: { status: state.programAxisPreviewPath?.gcodeExecutionProcess?.status || null, executionStepCount: state.programAxisPreviewPath?.gcodeExecutionProcess?.executionStepCount || 0, summary: state.programAxisPreviewPath?.gcodeExecutionProcess?.summary || null, }, lineExecutionTraceCount: state.programAxisPreviewPath?.lineExecutionTrace?.length || 0, axisValuesByLineCount: state.programAxisPreviewPath?.axisValuesByLine?.length || 0, programExecutionSourceMode: state.programExecutionSourceMode, taskHalRuntimeReadiness: state.taskHalRuntimeReadiness, }; }); const manifest = { apiName: "web-rtcp-5axis-xyzbc-trt-full-gcode-process-frame-capture", status: frameCount === sampleCount ? "complete" : "partial", capturedAt: new Date().toISOString(), projectRoot, outputDir, url: `http://127.0.0.1:${server.port}${appUrlPath}`, sourceRel, captureMethod: "Playwright drives the AXIS UI state one real programAxisPreviewPath sample at a time and screenshots after each 50ms sample.", samplePeriodMs, expectedSampleCount: sampleCount, capturedFrameCount: frames.length, firstFrame: frames[0] || null, lastFrame: frames[frames.length - 1] || null, gcodeExecutionProcess: finalState.gcodeExecutionProcess, lineExecutionTraceCount: finalState.lineExecutionTraceCount, axisValuesByLineCount: finalState.axisValuesByLineCount, finalState, frames, note: "executionStepCount is the expanded semantic G-code step count; capturedFrameCount is the 50ms visual frame count.", }; await writeFile(resolve(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8"); console.log(`gcode_full_50ms_screenshots=${outputDir}`); console.log(`captured_frames=${frames.length}`); console.log(`sample_period_ms=${samplePeriodMs}`); console.log(`manifest=${resolve(outputDir, "manifest.json")}`); } finally { await browser?.close().catch(() => {}); await server.close(); } async function waitForState(page, predicate, label, timeoutMs = 30000, context = {}) { const predicateText = predicate.toString(); await page.waitForFunction( ([source, selectedSourceRel, selectedSamplePeriodMs, selectedContext]) => { const state = window.webRtcp5AxisSimulation?.getState?.(); if (!state) return false; const sourceRel = selectedSourceRel; const samplePeriodMs = selectedSamplePeriodMs; const context = selectedContext || {}; return Function("state", "sourceRel", "samplePeriodMs", "context", `return (${source})(state, sourceRel, samplePeriodMs, context);`)( state, sourceRel, samplePeriodMs, context, ); }, [predicateText, sourceRel, samplePeriodMs, context], { timeout: timeoutMs }, ).catch(async (error) => { const state = await page.evaluate(() => window.webRtcp5AxisSimulation?.getState?.()); throw new Error(`Timed out waiting for ${label}: ${error.message}\n${JSON.stringify({ runState: state?.runState, activeLine: state?.activeLine, selectedGcodeSourceRel: state?.machineFileStaging?.selectedGcodeSourceRel, sampleIndex: state?.programExecutionSampleIndex, sampleCount: state?.programAxisPreviewPath?.sampleCount, operatorMessage: state?.operatorMessage, taskHalRuntimeReadiness: state?.taskHalRuntimeReadiness, }, null, 2)}`); }); } function startStaticServer(root) { const server = createServer((request, response) => { const url = new URL(request.url || "/", "http://127.0.0.1"); const decoded = decodeURIComponent(url.pathname); const relative = decoded === "/" ? "/index.html" : decoded; const target = resolve(root, `.${relative}`); if (!target.startsWith(root)) { response.writeHead(403); response.end("Forbidden"); return; } let stat; try { stat = statSync(target); if (!stat.isFile()) throw new Error("not file"); } catch { response.writeHead(404); response.end("Not found"); return; } response.writeHead(200, { "content-type": contentType(target), "content-length": stat.size, "cache-control": "no-store", }); createReadStream(target).pipe(response); }); return new Promise((resolveStart, rejectStart) => { server.on("error", rejectStart); server.listen(0, "127.0.0.1", () => { const address = server.address(); resolveStart({ port: address.port, close: () => new Promise((resolveClose) => server.close(resolveClose)), }); }); }); } function contentType(path) { if (path.endsWith(".html")) return "text/html; charset=utf-8"; if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript; charset=utf-8"; if (path.endsWith(".css")) return "text/css; charset=utf-8"; if (path.endsWith(".json")) return "application/json; charset=utf-8"; if (path.endsWith(".wasm")) return "application/wasm"; if (path.endsWith(".png")) return "image/png"; if (path.endsWith(".svg")) return "image/svg+xml"; return "application/octet-stream"; } function findSystemChromium() { return [ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", ].find((candidate) => existsSync(candidate)) || null; }