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 linuxCncSourceRoot = resolve(repoRoot, "linuxcnc"); 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 runSegmentMs = 5000; const pauseSegmentMs = 5000; const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); const outputDir = resolve(projectRoot, "working/screenshots", `estop-power-home-run-pause-50ms-${timestamp}`); const chromiumExecutable = process.env.CHROMIUM || findSystemChromium(); await mkdir(outputDir, { recursive: true }); const server = await startStaticServer(repoRoot); let browser; const events = []; const frames = []; const assertions = []; let capture = null; let captureError = null; 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); 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), 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 ), "xyzbc-trt real G-code loaded"); await page.waitForSelector("[data-five-axis-canvas]"); await assertCanvasHasPixels(page); await ensureEstopActive(page); await snapshot(page, "setup-estop-active"); capture = startFrameCapture(page); await clickAndWait(page, '[data-action="estop"]', "解除 ESTOP", (state) => ( state.machine?.estopActive === false && state.machine?.taskState === "estop-reset" && state.machine?.powerOn === false )); await clickAndWait(page, '[data-action="power"]', "上电", (state) => ( state.machine?.powerOn === true && state.machine?.taskState === "on" )); await clickAndWait(page, '[data-action="home-all"]', "Home All", (state) => ( state.machine?.allHomed === true && state.machine?.mode === "manual" )); await clickAndWait(page, '[data-action="toggle-auto-manual"]', "自动模式", (state) => ( state.machine?.mode === "auto" && state.machine?.interpState === "idle" )); await domClickAndWait(page, '[data-tool-id="btn_run"]', "Run", (state) => ( state.machine?.mode === "auto" && (state.runState === "running" || state.runState === "complete") && state.machine?.interpState === "reading" && state.programRuntimeFeedback ), 60000); await holdState(page, "Run 后执行 5 秒", runSegmentMs, (state) => ( state.runState === "running" && state.machine?.interpState === "reading" && state.machine?.taskPaused === false ), { requirePositionChange: true }); await domClickAndWait(page, '[data-tool-id="tbtn_pause"]', "第一次暂停", (state) => ( state.runState === "paused" && state.machine?.interpState === "paused" && state.machine?.taskPaused === true )); await holdState(page, "第一次暂停保持 5 秒", pauseSegmentMs, (state) => ( state.runState === "paused" && state.machine?.interpState === "paused" && state.machine?.taskPaused === true ), { freezePosition: true }); await domClickAndWait(page, '[data-tool-id="tbtn_pause"]', "第一次继续执行", (state) => ( state.runState === "running" && state.machine?.interpState === "reading" && state.machine?.taskPaused === false )); await holdState(page, "第一次继续执行 5 秒", runSegmentMs, (state) => ( state.runState === "running" && state.machine?.interpState === "reading" && state.machine?.taskPaused === false ), { requirePositionChange: true }); await domClickAndWait(page, '[data-tool-id="tbtn_pause"]', "第二次暂停", (state) => ( state.runState === "paused" && state.machine?.interpState === "paused" && state.machine?.taskPaused === true )); await holdState(page, "第二次暂停保持 5 秒", pauseSegmentMs, (state) => ( state.runState === "paused" && state.machine?.interpState === "paused" && state.machine?.taskPaused === true ), { freezePosition: true }); await domClickAndWait(page, '[data-tool-id="tbtn_pause"]', "第二次继续执行", (state) => ( state.runState === "running" && state.machine?.interpState === "reading" && state.machine?.taskPaused === false )); await holdState(page, "第二次继续执行 5 秒", runSegmentMs, (state) => ( state.runState === "running" && state.machine?.interpState === "reading" && state.machine?.taskPaused === false ), { requirePositionChange: true }); clearInterval(capture.timer); await capture.inFlight; capture = null; const finalState = await readStateSummary(page); const gcodeDataChecks = assertGcodeExecutionDataReasonable(frames); const manifest = { apiName: "xyzbc-trt-estop-power-home-run-pause-50ms-verification", status: "passed", capturedAt: new Date().toISOString(), projectRoot, linuxCncSourceRoot, appUrl: `http://127.0.0.1:${server.port}${appUrlPath}`, linuxCncSourceReferences: [ resolve(linuxCncSourceRoot, "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"), resolve(linuxCncSourceRoot, "configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.xml"), resolve(linuxCncSourceRoot, sourceRel), resolve(linuxCncSourceRoot, "src/emc/task/emctaskmain.cc"), resolve(linuxCncSourceRoot, "src/emc/nml_intf/emc.hh"), ], sourceRel, outputDir, captureMethod: "Playwright clicked AXIS DOM controls for ESTOP reset, power, Home All, Run, pause/resume, while a 50ms interval full-page screenshot loop recorded the visible web simulator.", samplePeriodMs, runSegmentMs, pauseSegmentMs, expectedSequence: [ "解除 ESTOP", "上电", "Home All", "自动模式", "Run", "Run 后执行 5 秒", "第一次暂停并保持 5 秒", "第一次继续执行 5 秒", "第二次暂停并保持 5 秒", "第二次继续执行 5 秒", ], gcodeDataChecks, events, assertions, capturedFrameCount: frames.length, firstFrame: frames[0] || null, lastFrame: frames[frames.length - 1] || null, frames, finalState, }; await writeFile(resolve(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8"); console.log(`verification_status=passed`); console.log(`screenshots=${outputDir}`); console.log(`captured_frames=${frames.length}`); console.log(`sample_period_ms=${samplePeriodMs}`); console.log(`manifest=${resolve(outputDir, "manifest.json")}`); } catch (error) { captureError = error; if (capture) { clearInterval(capture.timer); await capture.inFlight.catch(() => {}); } const failedManifest = { apiName: "xyzbc-trt-estop-power-home-run-pause-50ms-verification", status: "failed", capturedAt: new Date().toISOString(), projectRoot, linuxCncSourceRoot, sourceRel, outputDir, error: error instanceof Error ? error.stack || error.message : String(error), events, assertions, capturedFrameCount: frames.length, firstFrame: frames[0] || null, lastFrame: frames[frames.length - 1] || null, frames, }; await writeFile(resolve(outputDir, "manifest.json"), JSON.stringify(failedManifest, null, 2) + "\n", "utf8"); console.log(`verification_status=failed`); console.log(`screenshots=${outputDir}`); console.log(`captured_frames=${frames.length}`); console.log(`manifest=${resolve(outputDir, "manifest.json")}`); } finally { await browser?.close().catch(() => {}); await server.close(); } if (captureError) { throw captureError; } function startFrameCapture(page) { const startedAt = Date.now(); let index = 0; let inFlight = Promise.resolve(); let capturing = false; const timer = setInterval(() => { if (capturing) return; capturing = true; const frameIndex = index; index += 1; inFlight = (async () => { const elapsedMs = Date.now() - startedAt; const summary = await readStateSummary(page); const frameName = `frame-${String(frameIndex).padStart(5, "0")}-t${String(elapsedMs).padStart(6, "0")}ms-${slug(summary.runState)}.png`; await page.screenshot({ path: resolve(outputDir, frameName), fullPage: true }); frames.push({ index: frameIndex, elapsedMs, file: frameName, runState: summary.runState, taskState: summary.taskState, mode: summary.mode, interpState: summary.interpState, taskPaused: summary.taskPaused, motionPaused: summary.motionPaused, activeLine: summary.activeLine, sampleIndex: summary.programExecutionSampleIndex, sourceFile: summary.programUiExecution?.sourceFile || null, line: summary.programUiExecution?.line || null, statement: summary.programUiExecution?.statement || "", currentVelocity: summary.currentVelocity, }); })() .catch((error) => { captureError = error; }) .finally(() => { capturing = false; }); }, samplePeriodMs); return { timer, get inFlight() { return inFlight; } }; } async function clickAndWait(page, selector, label, predicate, timeoutMs = 45000) { const before = await readStateSummary(page); const startedAt = Date.now(); await page.click(selector); const after = await waitForState(page, predicate, label, timeoutMs); const event = { label, selector, at: new Date().toISOString(), elapsedMs: Date.now() - startedAt, before, after: summarizeForEvent(after), }; events.push(event); assertions.push({ label, passed: true, checkedAt: new Date().toISOString(), state: summarizeForEvent(after), }); return after; } async function domClickAndWait(page, selector, label, predicate, timeoutMs = 45000) { const before = await readStateSummary(page); const startedAt = Date.now(); await page.evaluate((targetSelector) => { const target = document.querySelector(targetSelector); if (!target) throw new Error(`missing click target ${targetSelector}`); target.click(); }, selector); const after = await waitForState(page, predicate, label, timeoutMs); const event = { label, selector, clickMethod: "dom-element-click", at: new Date().toISOString(), elapsedMs: Date.now() - startedAt, before, after: summarizeForEvent(after), }; events.push(event); assertions.push({ label, passed: true, checkedAt: new Date().toISOString(), state: summarizeForEvent(after), }); return after; } async function holdState(page, label, durationMs, predicate, options = {}) { const startedAt = Date.now(); let last = null; const frozen = options.freezePosition ? await readStateSummary(page) : null; const motionBaseline = options.requirePositionChange ? await readStateSummary(page) : null; let movedDuringHold = false; while (Date.now() - startedAt < durationMs) { const state = await readRawState(page); last = state; if (!predicate(state)) { throw new Error(`${label} failed at ${Date.now() - startedAt}ms: ${JSON.stringify(summarizeForEvent(state))}`); } if (frozen) { const live = await readStateSummary(page); assertPositionFrozen(frozen, live, label, Date.now() - startedAt); } if (motionBaseline && !movedDuringHold) { const live = await readStateSummary(page); movedDuringHold = hasPositionChanged(motionBaseline, live); } await page.waitForTimeout(100); } if (motionBaseline && !movedDuringHold) { throw new Error(`${label} failed: Position did not change while runState stayed running`); } const event = { label, at: new Date().toISOString(), durationMs: Date.now() - startedAt, state: summarizeForEvent(last), frozenPosition: frozen ? { axisPose: frozen.axisPose, dro: frozen.dro, canvasToolhead: frozen.canvasToolhead, canvasVismachPins: frozen.canvasVismachPins, } : null, movedDuringHold: motionBaseline ? movedDuringHold : null, }; events.push(event); assertions.push({ label, passed: true, checkedAt: new Date().toISOString(), state: summarizeForEvent(last), }); return last; } async function snapshot(page, label) { const summary = await readStateSummary(page); const file = `snapshot-${slug(label)}.png`; await page.screenshot({ path: resolve(outputDir, file), fullPage: true }); events.push({ label, at: new Date().toISOString(), snapshot: file, state: summary, }); } async function ensureEstopActive(page) { const state = await readRawState(page); if (state.machine?.estopActive === true || state.machine?.taskState === "estop") return; await page.evaluate(() => window.webRtcp5AxisSimulation.dispatch({ type: "ESTOP" })); await waitForState(page, (nextState) => ( nextState.machine?.estopActive === true && nextState.machine?.taskState === "estop" ), "setup ESTOP active"); } 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 readStateSummary(page).catch(() => null); throw new Error(`Timed out waiting for ${label}: ${error.message}\n${JSON.stringify(state, null, 2)}`); }); return readRawState(page); } async function readRawState(page) { return page.evaluate(() => window.webRtcp5AxisSimulation.getState()); } async function readStateSummary(page) { return page.evaluate(() => { const state = window.webRtcp5AxisSimulation.getState(); const pauseButton = document.querySelector('[data-tool-id="tbtn_pause"]'); const canvas = document.querySelector("[data-five-axis-canvas]"); const programPane = document.querySelector('[data-region="program"]'); const monitor = document.querySelector("[data-linuxcnc-process-monitor]"); const parseJsonDataset = (value) => { try { return value ? JSON.parse(value) : null; } catch { return null; } }; const canvasToolhead = parseJsonDataset(canvas?.dataset?.threeToolhead); const canvasVismach = parseJsonDataset(canvas?.dataset?.threeVismachModel); return { machineProfile: state.machineProfile, selectedGcodeSourceRel: state.machineFileStaging?.selectedGcodeSourceRel || null, taskHalLoaded: Boolean(state.taskHalRuntime?.loaded), taskHalRuntimeReadiness: state.taskHalRuntimeReadiness, runState: state.runState, taskState: state.machine?.taskState, powerOn: state.machine?.powerOn, estopActive: state.machine?.estopActive, allHomed: state.machine?.allHomed, mode: state.machine?.mode, interpState: state.machine?.interpState, interpResumeState: state.machine?.interpResumeState, taskPaused: state.machine?.taskPaused, motionPaused: state.machine?.motionPaused, rtcpState: state.rtcpState, kinsType: state.kinsType, activeLine: state.activeLine, programExecutionSourceMode: state.programExecutionSourceMode, programExecutionSampleIndex: state.programExecutionSampleIndex, programRuntimeFeedback: state.programRuntimeFeedback, axisPose: state.axisPose, dro: state.dro, programUiExecution: state.programUiExecution, samplePeriodMs: state.programAxisPreviewPath?.samplePeriodMs || null, sampleCount: state.programAxisPreviewPath?.sampleCount || state.programAxisPreviewPath?.samples?.length || 0, gcodeExecutionProcess: state.programAxisPreviewPath?.gcodeExecutionProcess || null, currentVelocity: state.feed?.currentVelocity || 0, operatorMessage: state.operatorMessage, pauseButton: pauseButton ? { action: pauseButton.dataset.action || null, paused: pauseButton.dataset.paused || null, title: pauseButton.title || null, ariaPressed: pauseButton.getAttribute("aria-pressed"), } : null, canvasDataset: canvas ? { width: canvas.width, height: canvas.height, threeToolAxis: canvas.dataset.threeToolAxis || null, threeToolGlyphAxis: canvas.dataset.threeToolGlyphAxis || null, } : null, canvasToolhead, canvasVismachPins: canvasVismach?.pins || null, programPaneDataset: programPane ? { ...programPane.dataset } : null, processMonitorDataset: monitor ? { ...monitor.dataset } : null, }; }); } function assertPositionFrozen(expected, actual, label, elapsedMs) { const checks = [ ["axisPose", expected.axisPose, actual.axisPose, ["x", "y", "z", "b", "c"]], ["dro", expected.dro, actual.dro, ["x", "y", "z", "b", "c", "tcpX", "tcpY", "tcpZ"]], ["canvasToolhead", expected.canvasToolhead, actual.canvasToolhead, ["x", "y", "z"]], ["canvasVismachPins", expected.canvasVismachPins, actual.canvasVismachPins, ["table-x", "saddle-y", "spindle-z", "tilt-b", "rotate-c"]], ]; for (const [name, before, after, keys] of checks) { if (!before || !after) continue; for (const key of keys) { const delta = Math.abs(Number(after[key] || 0) - Number(before[key] || 0)); if (delta > 1e-6) { throw new Error(`${label} position changed at ${elapsedMs}ms: ${name}.${key} ${before[key]} -> ${after[key]}`); } } } } function hasPositionChanged(before, after) { const checks = [ [before.axisPose, after.axisPose, ["x", "y", "z", "b", "c"], 1e-6], [before.dro, after.dro, ["x", "y", "z", "b", "c", "tcpX", "tcpY", "tcpZ"], 1e-6], [before.canvasToolhead, after.canvasToolhead, ["x", "y", "z"], 1e-6], [before.canvasVismachPins, after.canvasVismachPins, ["table-x", "saddle-y", "spindle-z", "tilt-b", "rotate-c"], 1e-6], ]; return checks.some(([left, right, keys, tolerance]) => ( left && right && keys.some((key) => Math.abs(Number(right[key] || 0) - Number(left[key] || 0)) > tolerance) )); } function assertGcodeExecutionDataReasonable(capturedFrames) { const runningFrames = capturedFrames.filter((frame) => frame.runState === "running"); const pausedFrames = capturedFrames.filter((frame) => frame.runState === "paused"); if (runningFrames.length < 5) { throw new Error(`G-code execution check failed: expected running frames, got ${runningFrames.length}`); } if (pausedFrames.length < 5) { throw new Error(`G-code execution check failed: expected paused frames, got ${pausedFrames.length}`); } const framesWithSource = runningFrames.filter((frame) => ( frame.sourceFile || frame.statement || Number(frame.line || 0) > 0 )); if (framesWithSource.length === 0) { throw new Error("G-code execution check failed: no running frame exposed source line data"); } const sampleIndexes = runningFrames .map((frame) => Number(frame.sampleIndex)) .filter(Number.isFinite); const activeLines = runningFrames .map((frame) => Number(frame.activeLine)) .filter(Number.isFinite); const velocities = capturedFrames .map((frame) => Number(frame.currentVelocity)) .filter(Number.isFinite); const runningVelocities = runningFrames .map((frame) => Number(frame.currentVelocity)) .filter(Number.isFinite); if (sampleIndexes.length === 0 || Math.max(...sampleIndexes) <= Math.min(...sampleIndexes)) { throw new Error("G-code execution check failed: sample index did not advance during running segments"); } if (activeLines.length === 0 || activeLines.some((line) => line < 1)) { throw new Error("G-code execution check failed: active G-code line values are invalid"); } if (velocities.some((velocity) => velocity < -1e-6)) { throw new Error("G-code execution check failed: negative velocity observed"); } if (!runningVelocities.some((velocity) => velocity > 0)) { throw new Error("G-code execution check failed: no positive velocity observed while running"); } return { apiName: "xyzbc-trt-gcode-execution-data-checks", runningFrameCount: runningFrames.length, pausedFrameCount: pausedFrames.length, framesWithSourceCount: framesWithSource.length, firstRunningFrame: summarizeFrameForDataCheck(runningFrames[0]), lastRunningFrame: summarizeFrameForDataCheck(runningFrames[runningFrames.length - 1]), minSampleIndex: Math.min(...sampleIndexes), maxSampleIndex: Math.max(...sampleIndexes), minActiveLine: Math.min(...activeLines), maxActiveLine: Math.max(...activeLines), maxRunningVelocity: Math.max(...runningVelocities), sourceRel, passed: true, }; } function summarizeFrameForDataCheck(frame = {}) { return { elapsedMs: frame.elapsedMs, runState: frame.runState, activeLine: frame.activeLine, sampleIndex: frame.sampleIndex, line: frame.line, statement: frame.statement, currentVelocity: frame.currentVelocity, sourceFile: frame.sourceFile, }; } function summarizeForEvent(state) { return { runState: state?.runState, taskState: state?.machine?.taskState || state?.taskState, powerOn: state?.machine?.powerOn ?? state?.powerOn, estopActive: state?.machine?.estopActive ?? state?.estopActive, allHomed: state?.machine?.allHomed ?? state?.allHomed, mode: state?.machine?.mode || state?.mode, interpState: state?.machine?.interpState || state?.interpState, taskPaused: state?.machine?.taskPaused ?? state?.taskPaused, motionPaused: state?.machine?.motionPaused ?? state?.motionPaused, activeLine: state?.activeLine, sampleIndex: state?.programExecutionSampleIndex, programExecutionSourceMode: state?.programExecutionSourceMode, programUiExecution: state?.programUiExecution, currentVelocity: state?.feed?.currentVelocity ?? state?.currentVelocity, operatorMessage: state?.operatorMessage, }; } async function assertCanvasHasPixels(page) { const result = await page.evaluate(() => { const canvas = document.querySelector("[data-five-axis-canvas]"); if (!canvas) return { ok: false, reason: "missing canvas" }; const width = canvas.width; const height = canvas.height; if (!width || !height) return { ok: false, reason: `invalid canvas size ${width}x${height}` }; const dataUrl = canvas.toDataURL("image/png"); return { ok: Boolean(dataUrl && dataUrl.length > 2000), width, height, dataUrlLength: dataUrl?.length || 0 }; }); if (!result.ok) { throw new Error(`preview canvas not renderable: ${JSON.stringify(result)}`); } assertions.push({ label: "WebGL/canvas preview rendered", passed: true, checkedAt: new Date().toISOString(), result, }); } 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")) return "text/javascript; charset=utf-8"; if (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() { const candidates = [ "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", ]; for (const candidate of candidates) { try { statSync(candidate); return candidate; } catch { // Continue probing common browser locations. } } return null; } function slug(value) { return String(value || "state") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 48) || "state"; }