import { mkdir, writeFile } from "node:fs/promises"; import { createReadStream, statSync } from "node:fs"; import { createServer } from "node:http"; import { resolve } from "node:path"; 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 = process.env.APP_URL_PATH || "/web-rtcp-5axis-xyzbc-trt-sim-plan/app/dist/index.html"; const sourceRel = "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc"; const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); const outputDir = resolve(projectRoot, "working/pause-position-traces", `pause-position-${timestamp}`); const chromiumExecutable = process.env.CHROMIUM || findSystemChromium(); 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(45000); const url = `http://127.0.0.1:${server.port}${appUrlPath}`; await page.goto(url, { waitUntil: "networkidle" }); await page.waitForFunction(() => Boolean(window.webRtcp5AxisSimulation?.getState)); await Promise.all([ page.evaluate(() => window.webRtcp5AxisSimulation.machineFileSeedReady), page.evaluate(() => window.webRtcp5AxisSimulation.interpreterRuntimeReady), page.evaluate(() => window.webRtcp5AxisSimulation.taskHalRuntimeReady), ]); await page.evaluate((selectedSourceRel) => { window.webRtcp5AxisSimulation.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel: selectedSourceRel }); }, sourceRel); await waitForState(page, (state) => ( state.machineFileStaging?.selectedGcodeSourceRel === sourceRel && Number(state.programAxisPreviewPath?.sampleCount || 0) > 0 ), "G-code loaded"); await ensureRunReady(page); await page.click('[data-tool-id="btn_run"]'); await waitForState(page, (state) => ( state.runState === "running" && state.machine?.interpState === "reading" && Number(state.programExecutionSampleIndex || 0) >= 0 ), "program running", 60000); await page.waitForTimeout(1800); const beforePause = await readTraceSample(page, "before-pause"); await page.click('[data-tool-id="tbtn_pause"]'); await waitForState(page, (state) => ( state.runState === "paused" && state.machine?.interpState === "paused" && state.machine?.taskPaused === true ), "pause button set paused"); const pauseStart = await readTraceSample(page, "pause-start"); const samples = [beforePause, pauseStart]; const startedAt = Date.now(); while (Date.now() - startedAt < 3500) { await page.waitForTimeout(100); samples.push(await readTraceSample(page, `pause-hold-${Date.now() - startedAt}`)); } const analysis = analyzePauseTrace(samples); const manifest = { apiName: "xyzbc-trt-pause-position-json-trace", status: analysis.positionChangedAfterPause ? "failed-position-changed" : "passed-position-frozen", capturedAt: new Date().toISOString(), appUrlPath, url, sourceRel, outputDir, pauseSelector: '[data-tool-id="tbtn_pause"]', analysis, samples, }; await writeFile(resolve(outputDir, "trace.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8"); console.log(`pause_position_status=${manifest.status}`); console.log(`trace=${resolve(outputDir, "trace.json")}`); console.log(`app_url_path=${appUrlPath}`); console.log(`position_changed=${analysis.positionChangedAfterPause}`); console.log(`changed_fields=${analysis.changedFields.join(",")}`); } finally { await browser?.close().catch(() => {}); await server.close(); } async function ensureRunReady(page) { await page.evaluate(() => window.webRtcp5AxisSimulation.dispatch({ type: "ESTOP" })); await waitForState(page, (state) => state.machine?.taskState === "estop", "estop active"); await page.evaluate(() => window.webRtcp5AxisSimulation.dispatch({ type: "RESET" })); await waitForState(page, (state) => state.machine?.taskState === "estop-reset", "estop reset"); await page.evaluate(() => window.webRtcp5AxisSimulation.dispatch({ type: "TOGGLE_POWER" })); await waitForState(page, (state) => state.machine?.taskState === "on" && state.machine?.powerOn === true, "power on"); await page.evaluate(() => window.webRtcp5AxisSimulation.dispatch({ type: "HOME" })); await waitForState(page, (state) => state.machine?.allHomed === true, "home all"); } async function readTraceSample(page, label) { return page.evaluate((sampleLabel) => { const state = window.webRtcp5AxisSimulation.getState(); const canvas = document.querySelector("[data-five-axis-canvas]"); const parseJson = (value) => { try { return value ? JSON.parse(value) : null; } catch { return null; } }; return { label: sampleLabel, capturedAt: new Date().toISOString(), runState: state.runState, taskState: state.machine?.taskState, mode: state.machine?.mode, interpState: state.machine?.interpState, taskPaused: state.machine?.taskPaused, activeLine: state.activeLine, sampleIndex: state.programExecutionSampleIndex, currentVelocity: state.feed?.currentVelocity, axisPose: pickAxes(state.axisPose), dro: pickDro(state.dro), runtimeAxisPose: pickAxes(state.programRuntimeFeedback?.axisPose), runtimeTcp: pickTcp(state.programRuntimeFeedback?.tcp), uiExecution: { status: state.programUiExecution?.status, sampleIndex: state.programUiExecution?.sampleIndex, sourceFile: state.programUiExecution?.sourceFile, line: state.programUiExecution?.line, statement: state.programUiExecution?.statement, joint: pickAxes(state.programUiExecution?.joint), tcp: pickTcp(state.programUiExecution?.tcp), }, canvasToolhead: parseJson(canvas?.dataset?.threeToolhead), canvasToolAxis: parseJson(canvas?.dataset?.threeToolAxis), pauseButton: { action: document.querySelector('[data-tool-id="tbtn_pause"]')?.dataset?.action || null, paused: document.querySelector('[data-tool-id="tbtn_pause"]')?.dataset?.paused || null, title: document.querySelector('[data-tool-id="tbtn_pause"]')?.title || null, }, }; function pickAxes(value = {}) { return value ? { x: Number(value.x || 0), y: Number(value.y || 0), z: Number(value.z || 0), a: Number(value.a || 0), b: Number(value.b || 0), c: Number(value.c || 0), } : null; } function pickDro(value = {}) { return value ? { x: Number(value.x || 0), y: Number(value.y || 0), z: Number(value.z || 0), a: Number(value.a || 0), b: Number(value.b || 0), c: Number(value.c || 0), tcpX: Number(value.tcpX || 0), tcpY: Number(value.tcpY || 0), tcpZ: Number(value.tcpZ || 0), } : null; } function pickTcp(value = {}) { return value ? { x: Number(value.x || 0), y: Number(value.y || 0), z: Number(value.z || 0), } : null; } }, label); } function analyzePauseTrace(samples) { const pauseSamples = samples.filter((sample) => sample.runState === "paused"); const baseline = pauseSamples[0] || null; const changedFields = []; if (baseline) { for (const sample of pauseSamples.slice(1)) { compareVector("axisPose", baseline.axisPose, sample.axisPose, changedFields); compareVector("dro", baseline.dro, sample.dro, changedFields); compareVector("runtimeAxisPose", baseline.runtimeAxisPose, sample.runtimeAxisPose, changedFields); compareVector("runtimeTcp", baseline.runtimeTcp, sample.runtimeTcp, changedFields); compareVector("uiExecution.joint", baseline.uiExecution?.joint, sample.uiExecution?.joint, changedFields); compareVector("uiExecution.tcp", baseline.uiExecution?.tcp, sample.uiExecution?.tcp, changedFields); compareVector("canvasToolhead", baseline.canvasToolhead, sample.canvasToolhead, changedFields, 1e-4); if (Number(sample.sampleIndex) !== Number(baseline.sampleIndex)) changedFields.push("sampleIndex"); if (Number(sample.currentVelocity) !== 0) changedFields.push("currentVelocity"); } } return { baselineLabel: baseline?.label || null, pausedSampleCount: pauseSamples.length, changedFields: [...new Set(changedFields)], positionChangedAfterPause: changedFields.length > 0, }; } function compareVector(name, before, after, changedFields, tolerance = 1e-6) { if (!before || !after) return; for (const key of Object.keys(before)) { const delta = Math.abs(Number(after[key] || 0) - Number(before[key] || 0)); if (delta > tolerance) changedFields.push(`${name}.${key}`); } } async function waitForState(page, predicate, label, timeoutMs = 30000) { const predicateText = predicate.toString(); await page.waitForFunction( ([source, selectedSourceRel]) => { const state = window.webRtcp5AxisSimulation?.getState?.(); if (!state) return false; const sourceRel = selectedSourceRel; return Function("state", "sourceRel", `return (${source})(state, sourceRel);`)(state, sourceRel); }, [predicateText, sourceRel], { timeout: timeoutMs }, ).catch(async (error) => { const state = await page.evaluate(() => window.webRtcp5AxisSimulation?.getState?.()).catch(() => null); throw new Error(`Timed out waiting for ${label}: ${error.message}\n${JSON.stringify({ runState: state?.runState, taskState: state?.machine?.taskState, mode: state?.machine?.mode, interpState: state?.machine?.interpState, sampleIndex: state?.programExecutionSampleIndex, operatorMessage: state?.operatorMessage, }, 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 itemStat; try { itemStat = statSync(target); if (!itemStat.isFile()) throw new Error("not a file"); } catch { response.writeHead(404); response.end("Not found"); return; } response.writeHead(200, { "content-type": contentType(target), "content-length": itemStat.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(file) { if (file.endsWith(".html")) return "text/html; charset=utf-8"; if (file.endsWith(".js") || file.endsWith(".mjs")) return "text/javascript; charset=utf-8"; if (file.endsWith(".css")) return "text/css; charset=utf-8"; if (file.endsWith(".json")) return "application/json; charset=utf-8"; if (file.endsWith(".wasm")) return "application/wasm"; if (file.endsWith(".svg")) return "image/svg+xml"; if (file.endsWith(".png")) return "image/png"; return "application/octet-stream"; } function findSystemChromium() { for (const candidate of ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"]) { try { statSync(candidate); return candidate; } catch { // Continue probing. } } return null; }