import { fileURLToPath } from "node:url"; import { loadWasmProbe } from "./wasm-probe-runtime.mjs"; import { writeBusinessReport } from "./business-report.mjs"; const capabilityId = "Robot:robot.createRecompute"; const saveId = "Robot:robot.saveReopen"; let cases = []; const results = []; let kernel, exitCode = 0; const documents = ["RobotBusinessPrimary", "RobotBusinessSecondary", "RobotSavedFirst", "RobotSavedSecond"]; const paths = ["/tmp/RobotBusinessFirst.FCStd", "/tmp/RobotBusinessSecond.FCStd"]; function run(args, check, cmd = "robot.createRecompute") { const input = {cmd, args}; const entry = {input, assertions: []}; cases.push(entry); try { entry.actual = JSON.parse(String(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify(input)]))); entry.assertions = Object.entries(check(entry.actual)).map(([name, passed]) => ({name, passed: Boolean(passed)})); } catch (error) { entry.error = String(error); entry.assertions.push({name: "native dispatch returns valid business result", passed: false}); } } function robot(documentName, objectName, axis1, axis2, count, operation = "upsert") { run({documentName, objectName, axis1, axis2, operation}, actual => ({ "native Robot recompute succeeds": actual.ok === true && actual.recomputed === true && actual.objectType === "Robot::RobotObject", "current document and object retained": actual.documentRetained === true && actual.documentName === documentName && actual.objectName === objectName && actual.objectCount === count, "native properties and Robot model use both requested angles": [actual.axis1, actual.robotAxis1].every(v => Number.isFinite(v) && Math.abs(v-axis1) < 0.0001) && [actual.axis2, actual.robotAxis2].every(v => Number.isFinite(v) && Math.abs(v-axis2) < 0.0001), })); } try { const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify([capabilityId, saveId])); if (!Array.isArray(ids)) throw new Error("FREECAD_BUSINESS_IDS must be an array"); for (const id of new Set(ids)) { if (![capabilityId, saveId].includes(id)) { results.push({capabilityId: id, status: "not_implemented", cases: []}); exitCode = 1; } } if (ids.some(id => [capabilityId, saveId].includes(id))) ({kernel} = await loadWasmProbe(fileURLToPath(new URL("../freecad-source/build-web/configure/bin", import.meta.url)), "FreeCADWebRobotRuntimeProbe")); if (ids.includes(capabilityId)) { results.push({capabilityId, status: "failed", cases}); const [a,b] = documents; robot(a, "Arm", 15, -10, 1); robot(a, "Support", -45.5, 80.25, 2); robot(a, "Arm", 30.25, -25.5, 2); robot(a, "Support", -45.5, 80.25, 2, "read"); robot(b, "Arm", 0, 90, 1); robot(a, "Arm", 30.25, -25.5, 2, "read"); for (const patch of [{axis1: null}, {axis2: "bad"}, {axis1: 361}, {axis2: -361}, {objectName: ""}, {documentName: "bad\u0000name"}, {operation: null}, {operation: false}, {operation: 1}, {operation: {}}, {operation: []}, {operation: ""}, {operation: "invalid"}]) { run({documentName: a, objectName: "Arm", axis1: 5, axis2: 6, ...patch}, actual => ({ "invalid native input rejected": actual.ok === false && actual.code === "robot_create_failed" && typeof actual.error === "string" && actual.error.length > 0, })); robot(a, "Arm", 30.25, -25.5, 2, "read"); } run({documentName: a, objectName: "Missing", operation: "read"}, actual => ({"missing object rejected": actual.ok === false && actual.code === "robot_create_failed"})); robot(a, "Arm", -120, 60, 2); robot(b, "Arm", 0, 90, 1, "read"); } if (ids.includes(saveId)) { cases = []; results.push({capabilityId: saveId, status: "failed", cases}); const [a, b, first, second] = documents; for (const documentName of [a, b]) run({documentName, operation: "close"}, actual => ({"setup cleanup": actual.ok === true && actual.closed === true})); robot(a, "Arm", 23.5, -76.25, 1); robot(a, "Support", -110, 42, 2); robot(b, "Arm", 81, 12, 1); const save = (target, path) => run({documentName: a, reopenedDocumentName: target, path}, actual => ({ "native current document saved and restored": actual.ok === true && actual.recomputed === true && actual.size > 0, "requested document and complete object count": actual.documentName === a && actual.reopenedDocumentName === target && actual.objectCount === 2 && actual.path === path, }), "robot.saveReopen"); save(first, paths[0]); robot(first, "Arm", 23.5, -76.25, 2, "read"); robot(first, "Support", -110, 42, 2, "read"); robot(a, "Arm", -34, 127.5, 2); robot(first, "Arm", 23.5, -76.25, 2, "read"); for (const patch of [{path: "bad.txt"}, {path: "/tmp/missing/file.FCStd"}, {documentName: "MissingRobotDocument"}, {reopenedDocumentName: a}, {reopenedDocumentName: first}]) { run({documentName: a, reopenedDocumentName: second, path: paths[1], ...patch}, actual => ({ "invalid save rejected": actual.ok === false && actual.code === "robot_save_failed" && typeof actual.error === "string" && actual.error.length > 0, }), "robot.saveReopen"); robot(a, "Arm", -34, 127.5, 2, "read"); } save(second, paths[1]); run({documentName: a, operation: "close"}, actual => ({"source closed before independent read": actual.ok === true && actual.closed === true})); robot(second, "Arm", -34, 127.5, 2, "read"); robot(second, "Support", -110, 42, 2, "read"); robot(b, "Arm", 81, 12, 1, "read"); } } catch (error) { exitCode = 1; cases.push({input: {operation: "execute"}, error: String(error), assertions: [{name: "business execution completes", passed: false}]}); if (!results.length) results.push({capabilityId: saveId, status: "failed", cases}); } finally { if (kernel) for (const documentName of documents) run({documentName, operation: "close"}, actual => ({"native document cleanup completes": actual.ok === true && actual.closed === true})); if (kernel) for (const path of paths) { try { // WasmFS exposes file operations, but not the legacy analyzePath helper. const removeFile = kernel.FS?.unlink ?? kernel.FS?.unlinkSync ?? kernel.FS?.rm ?? kernel.FS?.rmSync ?? kernel.FS_unlink; if (typeof removeFile !== "function") throw new Error("Wasm filesystem does not expose unlink"); try { removeFile.call(kernel.FS, path); } catch (error) { if (error?.code !== "ENOENT" && error?.errno !== 44) throw error; } let missing = false; try { // WasmFS builds may omit readFile; a second unlink is the native // existence check and must report ENOENT after cleanup. removeFile.call(kernel.FS, path); } catch (error) { if (error?.code !== "ENOENT" && error?.errno !== 44) throw error; missing = true; } cases.push({input: {operation: "cleanupFile", path}, assertions: [{name: "saved file released", passed: missing}]}); } catch (error) { cases.push({input: {operation: "cleanupFile", path}, error: String(error), assertions: [{name: "saved file released", passed: false}]}); } } try { kernel?.PThread?.terminateAllThreads?.(); } catch (error) { exitCode = 1; cases.push({input: {operation: "terminateThreads"}, error: String(error), assertions: [{name: "runtime cleanup", passed: false}]}); } for (const result of results) if (result.status !== "not_implemented") { result.status = exitCode === 0 && result.cases.length > 0 && result.cases.every(c => c.assertions.length > 0 && c.assertions.every(a => a.passed)) ? "passed" : "failed"; if (result.status !== "passed") { exitCode = 1; result.error = result.cases.filter(c => c.assertions.some(a => !a.passed)) .map(c => `${JSON.stringify(c.input)}: ${c.error || c.actual?.error || c.assertions.filter(a => !a.passed).map(a => a.name).join(", ")}`) .join("\n") || "Business execution or runtime cleanup failed"; } } await writeBusinessReport({schema: "freecad-web-business-cases/v1", results}); } process.exit(exitCode);