import assert from "node:assert/strict"; import { getOpfsRoot, loadTextFile, saveTextFile, } from "../../../runtime/opfs/file-service.js"; import { defaultMachinePaths, gcodeProgramPath, machineIniPath, normalizeOpfsPath, parameterFilePath, previewCachePath, sessionSnapshotPath, toolTablePath, } from "../../../runtime/opfs/path-model.js"; import { createSessionSnapshot, loadSessionSnapshot, saveSessionSnapshot, validateSessionSnapshot, } from "../../../runtime/opfs/snapshot-store.js"; import { loadGcodeProgram, loadMachineTextFiles, machineFilePaths, saveGcodeProgram, saveMachineTextFiles, } from "../../../runtime/opfs/machine-file-store.js"; import { restoreMachineParametersFromOpfs, saveMachineParametersToOpfs, } from "../../../runtime/opfs/linuxcnc-parameter-bridge.js"; import { loadMachineToolTableFromOpfs, saveMachineToolTableToOpfs, } from "../../../runtime/opfs/linuxcnc-tool-table-bridge.js"; import { loadMachineSessionFromOpfs, } from "../../../runtime/opfs/linuxcnc-machine-session-bridge.js"; class MockFileHandle { constructor(name) { this.name = name; this.textValue = ""; } async createWritable() { return { write: async (text) => { this.textValue = String(text); }, close: async () => {}, }; } async getFile() { return { text: async () => this.textValue, }; } } class MockDirectoryHandle { constructor(name = "") { this.name = name; this.dirs = new Map(); this.files = new Map(); } async getDirectoryHandle(name, options = {}) { if (!this.dirs.has(name)) { if (!options.create) { throw new Error(`missing directory: ${name}`); } this.dirs.set(name, new MockDirectoryHandle(name)); } return this.dirs.get(name); } async getFileHandle(name, options = {}) { if (!this.files.has(name)) { if (!options.create) { throw new Error(`missing file: ${name}`); } this.files.set(name, new MockFileHandle(name)); } return this.files.get(name); } } const root = new MockDirectoryHandle(); const storage = { getDirectory: async () => root, }; assert.equal(await getOpfsRoot(storage), root); assert.equal(normalizeOpfsPath("linuxcnc/machines/xyzab.ini"), "linuxcnc/machines/xyzab.ini"); assert.equal(machineIniPath("xyzab-tdr"), "linuxcnc/machines/xyzab-tdr/machine.ini"); assert.equal(toolTablePath("xyzab-tdr"), "linuxcnc/machines/xyzab-tdr/tool.tbl"); assert.equal(parameterFilePath("xyzab-tdr"), "linuxcnc/machines/xyzab-tdr/linuxcnc.var"); assert.equal(gcodeProgramPath("fixture.ngc"), "linuxcnc/gcode/fixture.ngc"); assert.equal(gcodeProgramPath("custom-fixture.ngc"), "linuxcnc/gcode/custom-fixture.ngc"); assert.equal(previewCachePath("fixture"), "linuxcnc/preview-cache/fixture/preview.json"); assert.equal(sessionSnapshotPath("session-1"), "linuxcnc/sessions/session-1/snapshot.json"); assert.equal( sessionSnapshotPath("session-1", "custom-snapshot.json"), "linuxcnc/sessions/session-1/custom-snapshot.json", ); assert.deepEqual(defaultMachinePaths("xyzab-tdr"), { ini: "linuxcnc/machines/xyzab-tdr/machine.ini", toolTable: "linuxcnc/machines/xyzab-tdr/tool.tbl", parameters: "linuxcnc/machines/xyzab-tdr/linuxcnc.var", }); assert.deepEqual(machineFilePaths("xyzab-tdr"), defaultMachinePaths("xyzab-tdr")); const snapshotPayload = { files: { ini: machineIniPath("xyzab-tdr"), toolTable: toolTablePath("xyzab-tdr"), }, }; const snapshot = createSessionSnapshot("session-1", snapshotPayload, { createdAt: "2026-06-08T00:00:00.000Z", metadata: { source: "node-smoke" }, }); assert.deepEqual(snapshot, { format: "linuxcnc-wasm-session-snapshot", version: 1, sessionId: "session-1", createdAt: "2026-06-08T00:00:00.000Z", metadata: { source: "node-smoke" }, payload: snapshotPayload, }); assert.equal(validateSessionSnapshot(snapshot, "session-1"), snapshot); assert.throws( () => validateSessionSnapshot({ ...snapshot, format: "other-format" }, "session-1"), /Unsupported session snapshot format/, ); assert.throws( () => validateSessionSnapshot({ ...snapshot, version: 99 }, "session-1"), /Unsupported session snapshot version/, ); assert.throws( () => validateSessionSnapshot({ ...snapshot, sessionId: "other-session" }, "session-1"), /Session snapshot id mismatch/, ); assert.throws( () => validateSessionSnapshot({ ...snapshot, metadata: null }, "session-1"), /snapshot metadata must be a plain object/, ); assert.throws( () => validateSessionSnapshot({ ...snapshot, payload: [] }, "session-1"), /snapshot payload must be a plain object/, ); await saveTextFile( "linuxcnc/machines/xyzab.ini", "[EMC]\nMACHINE = opfs-smoke\n", storage, ); assert.equal( await loadTextFile("linuxcnc/machines/xyzab.ini", storage), "[EMC]\nMACHINE = opfs-smoke\n", ); await saveSessionSnapshot("session-1", snapshotPayload, { storage, createdAt: "2026-06-08T00:00:00.000Z", metadata: { source: "node-smoke" }, }); assert.deepEqual(await loadSessionSnapshot("session-1", { storage }), snapshot); const customSnapshot = await saveSessionSnapshot("session-1", snapshotPayload, { storage, filename: "custom-snapshot.json", createdAt: "2026-06-08T00:00:00.000Z", metadata: { source: "node-smoke-custom" }, }); assert.equal( customSnapshot.metadata.source, "node-smoke-custom", ); assert.deepEqual( await loadSessionSnapshot("session-1", { storage, filename: "custom-snapshot.json", }), customSnapshot, ); await saveMachineTextFiles("xyzab-tdr", { ini: "[EMC]\nMACHINE = xyzab-tdr\n", toolTable: "T0 P0 ; no tool\nT2 P2 Z1.25 D0.25\n", parameters: "5161 0.0\n5162 0.0\n", }, { storage }); assert.deepEqual(await loadMachineTextFiles("xyzab-tdr", { storage }), { ini: "[EMC]\nMACHINE = xyzab-tdr\n", toolTable: "T0 P0 ; no tool\nT2 P2 Z1.25 D0.25\n", parameters: "5161 0.0\n5162 0.0\n", }); assert.equal( await saveGcodeProgram("fixture.ngc", "G0 X0 Y0\nM2\n", { storage }), "linuxcnc/gcode/fixture.ngc", ); assert.equal(await loadGcodeProgram("fixture.ngc", { storage }), "G0 X0 Y0\nM2\n"); assert.equal( await saveGcodeProgram("custom-fixture.ngc", "G1 X1 F10\nM2\n", { storage }), "linuxcnc/gcode/custom-fixture.ngc", ); assert.equal(await loadGcodeProgram("custom-fixture.ngc", { storage }), "G1 X1 F10\nM2\n"); const bridgeFiles = new Map(); const bridgeInterp = { writeTextFile(path, text) { bridgeFiles.set(path, text); }, readTextFile(path) { if (!bridgeFiles.has(path)) { throw new Error(`missing wasm file: ${path}`); } return bridgeFiles.get(path); }, restoreParameters(path) { return `restore_parameters=0\nrestore_path=${path}\n`; }, saveParameters(path, values) { bridgeFiles.set(path, "5161\t12.340000\n5162\t56.780000\n"); bridgeFiles.set(`${path}.bak`, "5161 0.0\n5162 0.0\n"); return `save_parameters=0\nsave_path=${path}\nvalues=${Object.keys(values).join(",")}\n`; }, loadToolTable(path, options = {}) { return [ "tooldata_load=0", `load_tool_path=${path}`, `random_toolchanger=${options.randomToolChanger === true ? 1 : 0}`, "", ].join("\n"); }, saveToolTable(path) { bridgeFiles.set(path, "T2 P2 Z+1.250000 D+0.250000 ;loaded by LinuxCNC\n"); return `tooldata_save=0\nsave_tool_path=${path}\n`; }, }; const bridgeIniSdk = { writeTextFile(path, text) { bridgeFiles.set(`ini:${path}`, text); }, getString(path, section, tag) { if ( path === "/work/ini-file-session.ini" && section === "RS274NGC" && tag === "PARAMETER_FILE" ) { return "custom.var"; } if ( path === "/work/ini-override-session.ini" && section === "RS274NGC" && tag === "PARAMETER_FILE" ) { return "ignored.var"; } if ( path === "/work/invalid-ini-file-session.ini" && section === "RS274NGC" && tag === "PARAMETER_FILE" ) { return "../escape.var"; } if ( path === "/work/ini-file-session.ini" && section === "EMCIO" && tag === "TOOL_TABLE" ) { return "custom-tool.tbl"; } if ( path === "/work/ini-override-session.ini" && section === "EMCIO" && tag === "TOOL_TABLE" ) { return "ignored-tool.tbl"; } if ( path === "/work/invalid-tool-file-session.ini" && section === "EMCIO" && tag === "TOOL_TABLE" ) { return "nested/tool.tbl"; } return null; }, getBool(path, section, tag) { if ( path === "/work/ini-derived-session.ini" && section === "EMCIO" && tag === "RANDOM_TOOLCHANGER" ) { return true; } return null; }, }; const restoredParameters = await restoreMachineParametersFromOpfs( bridgeInterp, "xyzab-tdr", { storage, wasmPath: "/work/bridge.var" }, ); assert.deepEqual(restoredParameters, { opfsPath: "linuxcnc/machines/xyzab-tdr/linuxcnc.var", wasmPath: "/work/bridge.var", result: "restore_parameters=0\nrestore_path=/work/bridge.var\n", }); assert.equal(bridgeFiles.get("/work/bridge.var"), "5161 0.0\n5162 0.0\n"); const savedParameters = await saveMachineParametersToOpfs( bridgeInterp, "xyzab-tdr", { 5161: 12.34, 5162: 56.78 }, { storage, wasmPath: "/work/bridge.var" }, ); assert.equal(savedParameters.opfsPath, "linuxcnc/machines/xyzab-tdr/linuxcnc.var"); assert.equal(savedParameters.backupOpfsPath, "linuxcnc/machines/xyzab-tdr/linuxcnc.var.bak"); assert.equal(savedParameters.result.includes("save_parameters=0"), true); assert.equal( await loadTextFile("linuxcnc/machines/xyzab-tdr/linuxcnc.var", storage), "5161\t12.340000\n5162\t56.780000\n", ); assert.equal( await loadTextFile("linuxcnc/machines/xyzab-tdr/linuxcnc.var.bak", storage), "5161 0.0\n5162 0.0\n", ); const loadedToolTable = await loadMachineToolTableFromOpfs( bridgeInterp, "xyzab-tdr", { storage, wasmPath: "/work/bridge-tool.tbl" }, ); assert.deepEqual(loadedToolTable, { opfsPath: "linuxcnc/machines/xyzab-tdr/tool.tbl", wasmPath: "/work/bridge-tool.tbl", result: "tooldata_load=0\nload_tool_path=/work/bridge-tool.tbl\nrandom_toolchanger=0\n", }); assert.equal(bridgeFiles.get("/work/bridge-tool.tbl"), "T0 P0 ; no tool\nT2 P2 Z1.25 D0.25\n"); const loadedRandomToolTable = await loadMachineToolTableFromOpfs( bridgeInterp, "xyzab-tdr", { storage, wasmPath: "/work/random-bridge-tool.tbl", randomToolChanger: true }, ); assert.deepEqual(loadedRandomToolTable, { opfsPath: "linuxcnc/machines/xyzab-tdr/tool.tbl", wasmPath: "/work/random-bridge-tool.tbl", result: "tooldata_load=0\nload_tool_path=/work/random-bridge-tool.tbl\nrandom_toolchanger=1\n", }); const savedToolTable = await saveMachineToolTableToOpfs( bridgeInterp, "xyzab-tdr", { storage, wasmPath: "/work/bridge-tool.tbl" }, ); assert.equal(savedToolTable.opfsPath, "linuxcnc/machines/xyzab-tdr/tool.tbl"); assert.equal(savedToolTable.wasmPath, "/work/bridge-tool.tbl"); assert.equal(savedToolTable.result.includes("tooldata_save=0"), true); assert.equal( await loadTextFile("linuxcnc/machines/xyzab-tdr/tool.tbl", storage), "T2 P2 Z+1.250000 D+0.250000 ;loaded by LinuxCNC\n", ); const loadedSession = await loadMachineSessionFromOpfs( bridgeInterp, "xyzab-tdr", { storage, iniWasmPath: "/work/session.ini", parameterWasmPath: "/work/session.var", toolTableWasmPath: "/work/session-tool.tbl", }, ); assert.deepEqual(loadedSession, { machineId: "xyzab-tdr", ini: { opfsPath: "linuxcnc/machines/xyzab-tdr/machine.ini", wasmPath: "/work/session.ini", }, parameters: { opfsPath: "linuxcnc/machines/xyzab-tdr/linuxcnc.var", wasmPath: "/work/session.var", result: "restore_parameters=0\nrestore_path=/work/session.var\n", }, toolTable: { opfsPath: "linuxcnc/machines/xyzab-tdr/tool.tbl", wasmPath: "/work/session-tool.tbl", result: "tooldata_load=0\nload_tool_path=/work/session-tool.tbl\nrandom_toolchanger=0\n", }, }); assert.equal(bridgeFiles.get("/work/session.ini"), "[EMC]\nMACHINE = xyzab-tdr\n"); assert.equal(bridgeFiles.get("/work/session.var"), "5161\t12.340000\n5162\t56.780000\n"); assert.equal( bridgeFiles.get("/work/session-tool.tbl"), "T2 P2 Z+1.250000 D+0.250000 ;loaded by LinuxCNC\n", ); const loadedRandomSession = await loadMachineSessionFromOpfs( bridgeInterp, "xyzab-tdr", { storage, iniWasmPath: "/work/random-session.ini", parameterWasmPath: "/work/random-session.var", toolTableWasmPath: "/work/random-session-tool.tbl", randomToolChanger: true, }, ); assert.equal( loadedRandomSession.toolTable.result, "tooldata_load=0\nload_tool_path=/work/random-session-tool.tbl\nrandom_toolchanger=1\n", ); const loadedIniDerivedRandomSession = await loadMachineSessionFromOpfs( bridgeInterp, "xyzab-tdr", { storage, iniSdk: bridgeIniSdk, iniWasmPath: "/work/ini-derived-session.ini", parameterWasmPath: "/work/ini-derived-session.var", toolTableWasmPath: "/work/ini-derived-session-tool.tbl", }, ); assert.equal( bridgeFiles.get("ini:/work/ini-derived-session.ini"), "[EMC]\nMACHINE = xyzab-tdr\n", ); assert.equal( loadedIniDerivedRandomSession.toolTable.result, "tooldata_load=0\nload_tool_path=/work/ini-derived-session-tool.tbl\nrandom_toolchanger=1\n", ); await saveMachineTextFiles("ini-file-session", { ini: [ "[EMC]", "MACHINE = ini-file-session", "", "[RS274NGC]", "PARAMETER_FILE = custom.var", "", "[EMCIO]", "TOOL_TABLE = custom-tool.tbl", "", ].join("\n"), }, { storage }); await saveTextFile( "linuxcnc/machines/ini-file-session/custom.var", "5161 77.0\n5162 88.0\n", storage, ); await saveTextFile( "linuxcnc/machines/ini-file-session/custom-tool.tbl", "T8 P8 Z4.5 D0.5\n", storage, ); const loadedIniFileSession = await loadMachineSessionFromOpfs( bridgeInterp, "ini-file-session", { storage, iniSdk: bridgeIniSdk, iniWasmPath: "/work/ini-file-session.ini", parameterWasmPath: "/work/ini-file-session.var", toolTableWasmPath: "/work/ini-file-session-tool.tbl", }, ); assert.equal( loadedIniFileSession.parameters.opfsPath, "linuxcnc/machines/ini-file-session/custom.var", ); assert.equal( loadedIniFileSession.toolTable.opfsPath, "linuxcnc/machines/ini-file-session/custom-tool.tbl", ); assert.equal(bridgeFiles.get("/work/ini-file-session.var"), "5161 77.0\n5162 88.0\n"); assert.equal(bridgeFiles.get("/work/ini-file-session-tool.tbl"), "T8 P8 Z4.5 D0.5\n"); assert.equal( loadedIniFileSession.toolTable.result, "tooldata_load=0\nload_tool_path=/work/ini-file-session-tool.tbl\nrandom_toolchanger=0\n", ); await saveMachineTextFiles("ini-override-session", { ini: [ "[EMC]", "MACHINE = ini-override-session", "", "[RS274NGC]", "PARAMETER_FILE = ignored.var", "", "[EMCIO]", "TOOL_TABLE = ignored-tool.tbl", "", ].join("\n"), }, { storage }); await saveTextFile( "linuxcnc/machines/ini-override-session/explicit.var", "5161 11.0\n5162 22.0\n", storage, ); await saveTextFile( "linuxcnc/machines/ini-override-session/explicit-tool.tbl", "T3 P3 Z1.75 D0.375\n", storage, ); const loadedIniOverrideSession = await loadMachineSessionFromOpfs( bridgeInterp, "ini-override-session", { storage, iniSdk: bridgeIniSdk, iniWasmPath: "/work/ini-override-session.ini", parameterFilename: "explicit.var", parameterWasmPath: "/work/ini-override-session.var", toolTableFilename: "explicit-tool.tbl", toolTableWasmPath: "/work/ini-override-session-tool.tbl", }, ); assert.equal( loadedIniOverrideSession.parameters.opfsPath, "linuxcnc/machines/ini-override-session/explicit.var", ); assert.equal( loadedIniOverrideSession.toolTable.opfsPath, "linuxcnc/machines/ini-override-session/explicit-tool.tbl", ); assert.equal(bridgeFiles.get("/work/ini-override-session.var"), "5161 11.0\n5162 22.0\n"); assert.equal(bridgeFiles.get("/work/ini-override-session-tool.tbl"), "T3 P3 Z1.75 D0.375\n"); await saveMachineTextFiles("invalid-ini-file-session", { ini: [ "[EMC]", "MACHINE = invalid-ini-file-session", "", "[RS274NGC]", "PARAMETER_FILE = ../escape.var", "", ].join("\n"), }, { storage }); await assert.rejects( () => loadMachineSessionFromOpfs( bridgeInterp, "invalid-ini-file-session", { storage, iniSdk: bridgeIniSdk, iniWasmPath: "/work/invalid-ini-file-session.ini", }, ), /Invalid parameter filename/, ); await saveMachineTextFiles("invalid-tool-file-session", { ini: [ "[EMC]", "MACHINE = invalid-tool-file-session", "", "[EMCIO]", "TOOL_TABLE = nested/tool.tbl", "", ].join("\n"), }, { storage }); await assert.rejects( () => loadMachineSessionFromOpfs( bridgeInterp, "invalid-tool-file-session", { storage, iniSdk: bridgeIniSdk, iniWasmPath: "/work/invalid-tool-file-session.ini", }, ), /Invalid tool table filename/, ); await assert.rejects( () => loadTextFile("linuxcnc/machines/missing.ini", storage), /missing file/, ); await assert.rejects( () => saveTextFile("../escape.ini", "", storage), /Invalid OPFS path/, ); assert.throws( () => machineIniPath("../escape"), /Invalid machine id/, ); assert.throws( () => sessionSnapshotPath("session-1", "nested/snapshot.json"), /Invalid session snapshot filename/, ); assert.throws( () => gcodeProgramPath("../escape.ngc"), /Invalid G-code filename/, ); await assert.rejects( () => saveGcodeProgram("nested/fixture.ngc", "", { storage }), /Invalid G-code filename/, ); await assert.rejects( () => loadGcodeProgram("../escape.ngc", { storage }), /Invalid G-code filename/, ); await assert.rejects( () => saveSessionSnapshot("session-1", {}, { storage, filename: "nested/snapshot.json", }), /Invalid session snapshot filename/, ); await assert.rejects( () => loadSessionSnapshot("session-1", { storage, filename: "../snapshot.json", }), /Invalid session snapshot filename/, ); await assert.rejects( () => saveMachineTextFiles("xyzab-tdr", null, { storage }), /machine files must be a plain object/, ); await saveTextFile(sessionSnapshotPath("bad-json"), "{", storage); await assert.rejects( () => loadSessionSnapshot("bad-json", { storage }), /Invalid session snapshot JSON/, ); await saveTextFile( sessionSnapshotPath("bad-format"), JSON.stringify({ ...snapshot, sessionId: "bad-format", format: "other-format" }), storage, ); await assert.rejects( () => loadSessionSnapshot("bad-format", { storage }), /Unsupported session snapshot format/, ); await saveTextFile( sessionSnapshotPath("bad-version"), JSON.stringify({ ...snapshot, sessionId: "bad-version", version: 99 }), storage, ); await assert.rejects( () => loadSessionSnapshot("bad-version", { storage }), /Unsupported session snapshot version/, ); await saveTextFile( sessionSnapshotPath("bad-session-id"), JSON.stringify({ ...snapshot, sessionId: "other-session" }), storage, ); await assert.rejects( () => loadSessionSnapshot("bad-session-id", { storage }), /Session snapshot id mismatch/, ); await assert.rejects( () => loadSessionSnapshot("missing-session", { storage }), /missing directory|missing file/, ); await assert.rejects( () => getOpfsRoot({}), /OPFS is not available/, ); console.log("opfs_file_service_node_smoke=ok");