#!/usr/bin/env node const path = require("path"); const fs = require("fs"); const os = require("os"); const { pathToFileURL } = require("url"); const wasmDir = path.join(__dirname, "web", "public"); const cjsLoader = path.join(os.tmpdir(), `cnc_sim_node_loader_${process.pid}.cjs`); fs.copyFileSync(path.join(wasmDir, "cnc_sim.js"), cjsLoader); process.on("exit", () => { fs.rmSync(cjsLoader, { force: true }); }); const createCncSimModule = require(cjsLoader); if (typeof createCncSimModule !== "function") { throw new Error("cnc_sim.js did not export a Node-loadable createCncSimModule function"); } const EVENT_TYPE = { 2: "comment", 9: "rapid", 10: "linear-feed", 13: "program-end", 14: "rtcp-pivot", 15: "kinematics-switch", }; const EVENT_OFFSETS = { type: 4, line: 8, tool: 16, feed: 24, dwell: 40, end: 120, reserved: 268, }; function readEvent(module, ptr) { const view = new DataView(module.HEAPU8.buffer, ptr, 272); return { type: EVENT_TYPE[view.getInt32(EVENT_OFFSETS.type, true)] || "other", line: view.getInt32(EVENT_OFFSETS.line, true), tool: view.getInt32(EVENT_OFFSETS.tool, true), feed: view.getFloat64(EVENT_OFFSETS.feed, true), dwellSeconds: view.getFloat64(EVENT_OFFSETS.dwell, true), endX: view.getFloat64(EVENT_OFFSETS.end + 0, true), endY: view.getFloat64(EVENT_OFFSETS.end + 8, true), endZ: view.getFloat64(EVENT_OFFSETS.end + 16, true), endA: view.getFloat64(EVENT_OFFSETS.end + 24, true), endB: view.getFloat64(EVENT_OFFSETS.end + 32, true), endC: view.getFloat64(EVENT_OFFSETS.end + 40, true), reserved: view.getInt32(EVENT_OFFSETS.reserved, true), }; } function writeString(module, value) { const bytes = module.lengthBytesUTF8(value) + 1; const ptr = module._malloc(bytes); module.stringToUTF8(value, ptr, bytes); return { ptr, len: bytes - 1 }; } function expectKinematicsSwitch(events, line, kinstype, label) { if (!events.some((event) => event.type === "kinematics-switch" && event.line === line && event.reserved === kinstype)) { throw new Error(`expected LinuxCNC WASM ${label} kinematics switch ${kinstype} on line ${line}`); } } function expectEvent(events, predicate, message) { if (!events.some(predicate)) { throw new Error(message); } } async function expectErrorContaining(action, expectedText, message) { try { await action(); throw new Error(message); } catch (error) { if (!String(error?.message ?? error).includes(expectedText)) { throw error; } } } function expectRtcpPivot(events, expected, message) { expectEvent( events, (event) => event.type === "rtcp-pivot" && event.line === expected.line && event.reserved === expected.reserved && near(event.dwellSeconds, expected.dwellSeconds) && (expected.endX === undefined || near(event.endX, expected.endX)) && (expected.endY === undefined || near(event.endY, expected.endY)) && (expected.endZ === undefined || near(event.endZ, expected.endZ)) && (expected.endA === undefined || near(event.endA, expected.endA)) && (expected.endB === undefined || near(event.endB, expected.endB)) && (expected.endC === undefined || near(event.endC, expected.endC)), message, ); } function near(actual, expected) { return Math.abs(actual - expected) < 1e-6; } let completedSteps = 0; function loadSwitchkinsCases() { const configCases = JSON.parse( fs.readFileSync(path.join(wasmDir, "linuxcnc_switchkins_remap_config_cases.json"), "utf8"), ); if (!Array.isArray(configCases) || configCases.length === 0) { throw new Error("expected non-empty generated LinuxCNC switchkins config cases"); } for (const [index, configCase] of configCases.entries()) { if ( !configCase || typeof configCase.field !== "string" || typeof configCase.value !== "string" || !Number.isInteger(configCase.m428) || !Number.isInteger(configCase.m429) || !Number.isInteger(configCase.m430) ) { throw new Error(`invalid generated LinuxCNC switchkins config case at index ${index}`); } } return configCases.map((configCase) => ({ label: `${configCase.field}=${configCase.value}`, config: { backend: "linuxcnc-rs274", [configCase.field]: configCase.value }, program: configCase.m430 >= 0 ? "M428\nM429\nM430\n" : "M428\nM429\n", expected: [ [1, configCase.m428], [2, configCase.m429], ...(configCase.m430 >= 0 ? [[3, configCase.m430]] : []), ], })); } async function runStep(name, action) { try { await action(); completedSteps += 1; } catch (error) { const detail = error && error.stack ? error.stack : error; throw new Error(`Node WASM smoke step "${name}" failed: ${detail}`); } } (async () => { const module = await createCncSimModule({ locateFile: (file) => path.join(wasmDir, file), print: () => {}, printErr: () => {}, }); const create = module.cwrap("cnc_sim_create", "number", []); const destroy = module.cwrap("cnc_sim_destroy", null, ["number"]); const reset = module.cwrap("cnc_sim_reset", null, ["number"]); const setDialect = module.cwrap("cnc_sim_set_dialect", "number", ["number", "number"]); const setCallback = module.cwrap("cnc_sim_set_event_callback", "number", ["number", "number", "number"]); const loadConfig = module.cwrap("cnc_sim_load_config_json", "number", ["number", "number", "number"]); const parseProgram = module.cwrap("cnc_sim_parse_program", "number", ["number", "number", "number"]); const lastError = module.cwrap("cnc_sim_last_error", "number", ["number"]); const throwLastError = (targetHandle) => { throw new Error(module.UTF8ToString(lastError(targetHandle))); }; const initializeHandle = (targetHandle) => { reset(targetHandle); if (setDialect(targetHandle, 0) !== 0) { throwLastError(targetHandle); } }; const loadJsonConfig = (targetHandle, value) => { const config = writeString(module, JSON.stringify(value)); try { if (loadConfig(targetHandle, config.ptr, config.len) !== 0) { throwLastError(targetHandle); } } finally { module._free(config.ptr); } }; const parseText = (targetHandle, value) => { const program = writeString(module, value); try { if (parseProgram(targetHandle, program.ptr, program.len) !== 0) { throwLastError(targetHandle); } } finally { module._free(program.ptr); } }; const attachCallback = (targetHandle, targetCallbackPtr) => { if (setCallback(targetHandle, targetCallbackPtr, 0) !== 0) { throwLastError(targetHandle); } }; const handle = create(); if (!handle) { throw new Error("cnc_sim_create returned null"); } let callbackPtr = 0; try { initializeHandle(handle); loadJsonConfig(handle, { backend: "linuxcnc-rs274" }); const events = []; callbackPtr = module.addFunction((eventPtr) => { events.push(readEvent(module, eventPtr)); return 0; }, "iii"); attachCallback(handle, callbackPtr); const parseCaseWithHandle = (testCase) => { reset(handle); events.length = 0; loadJsonConfig(handle, testCase.config); parseText(handle, testCase.program); }; const withConfiguredHandle = async (label, config, program, action) => { events.length = 0; const targetHandle = create(); if (!targetHandle) { throw new Error(`cnc_sim_create returned null for ${label} case`); } try { initializeHandle(targetHandle); attachCallback(targetHandle, callbackPtr); loadJsonConfig(targetHandle, config); parseText(targetHandle, program); await action(); } finally { destroy(targetHandle); } }; await runStep("basic LinuxCNC RS274 parse", async () => { parseText(handle, "G21 G90\nG0 X0\nG1 X5 F100\nM30\n"); }); await runStep("basic LinuxCNC RS274 linear-feed event", async () => { expectEvent(events, (event) => event.type === "linear-feed" && event.endX === 5, "expected LinuxCNC WASM linear-feed event ending at X5"); }); await runStep("basic LinuxCNC RS274 program-end event", async () => { expectEvent(events, (event) => event.type === "program-end", "expected LinuxCNC WASM program-end event"); }); await runStep("default switchkins remap parse", async () => { events.length = 0; parseText(handle, "M428\nM429\nM430\n"); }); for (const kinstype of [1, 0, 2]) { await runStep(`default switchkins kinematics switch ${kinstype}`, async () => { expectEvent(events, (event) => event.type === "kinematics-switch" && event.reserved === kinstype, `expected LinuxCNC WASM kinematics switch ${kinstype}`); }); await runStep(`default switchkins M68 E3 Q${kinstype} side effect`, async () => { expectEvent(events, (event) => event.type === "comment" && event.reserved === 68 && event.tool === 3 && event.feed === kinstype, `expected LinuxCNC WASM M68 E3 Q${kinstype} remap side effect`); }); } await runStep("default switchkins M66 E0 L0 side effect", async () => { expectEvent(events, (event) => event.type === "comment" && event.reserved === 66 && event.tool === 0 && event.feed === 0, "expected LinuxCNC WASM M66 E0 L0 remap side effect"); }); const switchkinsCases = loadSwitchkinsCases(); await runStep("generated switchkins config cases load", async () => { if (switchkinsCases.length === 0) { throw new Error("expected generated LinuxCNC WASM switchkins config cases"); } }); for (const testCase of switchkinsCases) { await runStep(`generated switchkins ${testCase.label}`, async () => { parseCaseWithHandle(testCase); for (const [line, kinstype] of testCase.expected) { expectKinematicsSwitch(events, line, kinstype, testCase.label); } }); } await runStep("5axiskins RTCP parse", async () => { reset(handle); events.length = 0; loadJsonConfig(handle, { backend: "linuxcnc-rs274", rtcp: { enabled: true, toolLength: 250 }, kinematics: "5axiskins", pivotLength: 250, }); parseText(handle, "M428\nG0 X260 Y20 Z280 B90 C0\n"); }); await runStep("5axiskins RTCP kinematics switch", async () => { expectKinematicsSwitch(events, 1, 0, "5axiskins"); }); await runStep("5axiskins RTCP pivot event", async () => { expectRtcpPivot(events, { line: 2, reserved: 0, dwellSeconds: 250, endX: 10, endY: 20, endZ: 30, endB: 90, endC: 0, }, "expected LinuxCNC WASM 5axiskins RTCP pivot from LinuxCNC 5axiskins inverse"); }); await runStep("userk RTCP parse", async () => { reset(handle); events.length = 0; loadJsonConfig(handle, { backend: "linuxcnc-rs274", rtcp: { enabled: true, toolLength: 250 }, }); parseText(handle, "M430\nG0 X260 Y20 Z280 B90 C45\n"); }); await runStep("userk RTCP kinematics switch", async () => { expectKinematicsSwitch(events, 1, 2, "userk"); }); await runStep("userk RTCP pivot event", async () => { expectRtcpPivot(events, { line: 2, reserved: 2, dwellSeconds: 250, endX: 260, endY: 20, endZ: 280, endB: 90, endC: 45, }, "expected LinuxCNC WASM userk RTCP pivot from LinuxCNC userk identity inverse"); }); await runStep("configured xyzbc-trt RTCP parse", async () => { await withConfiguredHandle( "configured xyzbc-trt", { backend: "linuxcnc-rs274", rtcp: { enabled: false, toolLength: 11 }, xyzbcTrt: { xRotPoint: 3, yRotPoint: -4, zRotPoint: 5, xOffset: 2, zOffset: 7, conventionalDirections: true, }, }, "M428\nG43.4\nG0 X30 Y40 Z50 B20 C-30\n", async () => { }, ); }); await runStep("configured xyzbc-trt kinematics switch", async () => { expectKinematicsSwitch(events, 1, 1, "configured xyzbc-trt"); }); await runStep("configured xyzbc-trt RTCP pivot event", async () => { expectRtcpPivot(events, { line: 3, reserved: 1, dwellSeconds: 11, endX: -4.814629, endY: 47.605118, endZ: 48.160567, endB: 20, endC: -30, }, "expected LinuxCNC WASM configured xyzbc-trt RTCP pivot from LinuxCNC trtfuncs inverse"); }); await runStep("configured xyzac-trt RTCP parse", async () => { await withConfiguredHandle( "configured xyzac-trt", { backend: "linuxcnc-rs274", rtcp: { enabled: false, toolLength: 7 }, switchkins: "xyzac-trt", xyzbcTrt: { yOffset: 20, zOffset: 10, }, }, "M428\nG43.4\nG0 X12 Y-8 Z42 A35 C-25\n", async () => { }, ); }); await runStep("configured xyzac-trt kinematics switch", async () => { expectKinematicsSwitch(events, 1, 1, "configured xyzac-trt"); }); await runStep("configured xyzac-trt RTCP pivot event", async () => { expectRtcpPivot(events, { line: 3, reserved: 1, dwellSeconds: 7, endX: 7.494747, endY: -20.815946, endZ: 18.939732, endA: 35, endB: 0, endC: -25, }, "expected LinuxCNC WASM configured xyzac-trt RTCP pivot from LinuxCNC xyzab_tdr inverse"); }); await runStep("web wasm-core wrapper defaults and options", async () => { globalThis.createCncSimModule = createCncSimModule; const wasmCoreUrl = pathToFileURL(path.join(__dirname, "web", "src", "wasm-core.js")).href; const { createWasmSimulator } = await import(wasmCoreUrl); const webSimulator = await createWasmSimulator({ locateFile: (file) => path.join(wasmDir, file), print: () => {}, printErr: () => {}, }); try { const defaultBackendEvents = webSimulator.parse("G21 G90\nG1 X3 F30\nM30\n", "linuxcnc"); if (!defaultBackendEvents.some((event) => event.type === "linear-feed" && event.line === 2 && near(event.end.x, 3))) { throw new Error("expected web wasm-core default backend to use LinuxCNC RS274"); } if (webSimulator.fs.opfs !== null) { throw new Error("expected Node wasm-core wrapper to leave OPFS disabled"); } await expectErrorContaining( () => webSimulator.parseFile("programs/node-smoke.ngc", "linuxcnc", { backend: "linuxcnc-rs274" }), "OPFS workspace is not available", "expected Node wasm-core parseFile to require OPFS", ); await expectErrorContaining( () => webSimulator.parseWithParameterFile("G21 G90\nM30\n", "parameters/node.var", "linuxcnc", { backend: "linuxcnc-rs274", }), "OPFS workspace is not available", "expected Node wasm-core parseWithParameterFile to require OPFS", ); await expectErrorContaining( () => webSimulator.parseFileWithParameterFile( "programs/node-smoke.ngc", "parameters/node.var", "linuxcnc", { backend: "linuxcnc-rs274" }, ), "OPFS workspace is not available", "expected Node wasm-core parseFileWithParameterFile to require OPFS", ); const webEvents = webSimulator.parse( "M428\nG43.4\nG0 X12 Y-8 Z42 A35 C-25\n", "linuxcnc", { backend: "linuxcnc-rs274", rtcp: { enabled: false, toolLength: 7 }, switchkins: "xyzac-trt", xyzbcTrt: { yOffset: 20, zOffset: 10, }, }, ); expectEvent(webEvents, (event) => event.type === "rtcp-pivot" && event.line === 3 && event.reserved === 1 && near(event.dwellSeconds, 7) && near(event.end.x, 7.494747) && near(event.end.y, -20.815946) && near(event.end.z, 18.939732) && near(event.end.a, 35) && near(event.end.b, 0) && near(event.end.c, -25), "expected web wasm-core options to pass LinuxCNC xyzac-trt RTCP config into WASM"); } finally { webSimulator.dispose(); delete globalThis.createCncSimModule; } }); } finally { if (callbackPtr) { module.removeFunction(callbackPtr); } destroy(handle); } console.log(`web wasm node smoke passed (${completedSteps} steps)`); })().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); }).finally(() => { fs.rmSync(cjsLoader, { force: true }); });