docs: record pause wasm implementation plan
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
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 runStableBeforeFirstPauseMs = Math.max(Number(process.env.RUN_STABLE_BEFORE_FIRST_PAUSE_MS || 0), 0);
|
||||
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.runState === "idle"
|
||||
));
|
||||
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="run"]', "Run", (state) => (
|
||||
state.machine?.mode === "auto" &&
|
||||
(state.runState === "running" || state.runState === "complete") &&
|
||||
state.machine?.interpState === "reading" &&
|
||||
state.programRuntimeFeedback
|
||||
), 60000);
|
||||
|
||||
if (runStableBeforeFirstPauseMs > 0) {
|
||||
await holdState(page, `Run 后稳定运行 ${runStableBeforeFirstPauseMs}ms`, runStableBeforeFirstPauseMs, (state) => (
|
||||
state.runState === "running" &&
|
||||
state.machine?.interpState === "reading" &&
|
||||
state.machine?.taskPaused === false
|
||||
), { requirePositionChange: true });
|
||||
}
|
||||
|
||||
await clickAndWait(page, '[data-tool-id="tbtn_pause"]', "第一次暂停", (state) => (
|
||||
state.runState === "paused" &&
|
||||
state.machine?.interpState === "paused" &&
|
||||
state.machine?.taskPaused === true
|
||||
));
|
||||
await holdState(page, "第一次暂停保持 5 秒", 5000, (state) => (
|
||||
state.runState === "paused" &&
|
||||
state.machine?.interpState === "paused" &&
|
||||
state.machine?.taskPaused === true
|
||||
), { freezePosition: true });
|
||||
|
||||
await clickAndWait(page, '[data-tool-id="tbtn_pause"]', "第一次继续执行", (state) => (
|
||||
state.runState === "running" &&
|
||||
state.machine?.interpState === "reading" &&
|
||||
state.machine?.taskPaused === false
|
||||
));
|
||||
await holdState(page, "第一次继续执行 10 秒", 10000, (state) => (
|
||||
state.runState === "running" &&
|
||||
state.machine?.interpState === "reading" &&
|
||||
state.machine?.taskPaused === false
|
||||
));
|
||||
|
||||
await clickAndWait(page, '[data-tool-id="tbtn_pause"]', "第二次暂停", (state) => (
|
||||
state.runState === "paused" &&
|
||||
state.machine?.interpState === "paused" &&
|
||||
state.machine?.taskPaused === true
|
||||
));
|
||||
await holdState(page, "第二次暂停保持 5 秒", 5000, (state) => (
|
||||
state.runState === "paused" &&
|
||||
state.machine?.interpState === "paused" &&
|
||||
state.machine?.taskPaused === true
|
||||
), { freezePosition: true });
|
||||
|
||||
await clickAndWait(page, '[data-tool-id="tbtn_pause"]', "第二次继续执行", (state) => (
|
||||
state.runState === "running" &&
|
||||
state.machine?.interpState === "reading" &&
|
||||
state.machine?.taskPaused === false
|
||||
));
|
||||
await holdState(page, "第二次继续执行 10 秒", 10000, (state) => (
|
||||
state.runState === "running" &&
|
||||
state.machine?.interpState === "reading" &&
|
||||
state.machine?.taskPaused === false
|
||||
));
|
||||
|
||||
clearInterval(capture.timer);
|
||||
await capture.inFlight;
|
||||
capture = null;
|
||||
|
||||
const finalState = await readStateSummary(page);
|
||||
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,
|
||||
runStableBeforeFirstPauseMs,
|
||||
expectedSequence: [
|
||||
"解除 ESTOP",
|
||||
"上电",
|
||||
"Home All",
|
||||
"Run",
|
||||
...(runStableBeforeFirstPauseMs > 0 ? [`Run 后稳定运行 ${runStableBeforeFirstPauseMs}ms`] : []),
|
||||
"第一次暂停并保持 5 秒",
|
||||
"第一次继续执行 10 秒",
|
||||
"第二次暂停并保持 5 秒",
|
||||
"第二次继续执行 10 秒",
|
||||
],
|
||||
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();
|
||||
const timer = setInterval(() => {
|
||||
const frameIndex = index;
|
||||
index += 1;
|
||||
inFlight = inFlight
|
||||
.catch(() => {})
|
||||
.then(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,
|
||||
activeLine: summary.activeLine,
|
||||
sampleIndex: summary.programExecutionSampleIndex,
|
||||
sourceFile: summary.programUiExecution?.sourceFile || null,
|
||||
line: summary.programUiExecution?.line || null,
|
||||
statement: summary.programUiExecution?.statement || "",
|
||||
currentVelocity: summary.currentVelocity,
|
||||
});
|
||||
});
|
||||
}, 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 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,
|
||||
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 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,
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user