702 lines
26 KiB
JavaScript
702 lines
26 KiB
JavaScript
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";
|
|
}
|