Files
cnc_wams/web-rtcp-5axis-xyzbc-trt-sim-plan/tools/verify-estop-power-home-run-pause-50ms.mjs
2026-07-09 18:12:01 -04:00

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";
}