303 lines
12 KiB
JavaScript
303 lines
12 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 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;
|
|
}
|