import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync, spawn } from "node:child_process"; import { once } from "node:events"; import { createHash } from "node:crypto"; import { fileURLToPath, pathToFileURL } from "node:url"; import { loadWasmProbe } from "./wasm-probe-runtime.mjs"; import { writeBusinessReport } from "./business-report.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const out = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-techdraw-business-")); const capabilityId = "TechDraw:techdraw.createRecompute"; const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify([capabilityId])); const cases = []; const imageId = "TechDraw:desktop:TechDraw_Image"; const imageCases = []; const saveId = "TechDraw:techdraw.nativeSaveReopen"; const saveCases = []; const importExportId = "TechDraw:techdraw.nativeImportExport"; const dxfExportId = "business-format:TechDraw:export:.dxf"; const importExportCases = []; let exportFailure; let exitCode = 0; let report; let server; let serverClosed; try { execFileSync(path.join(root, "web/node_modules/.bin/tsc"), [ "--target", "ES2022", "--module", "ESNext", "--moduleResolution", "Bundler", "--strict", "--skipLibCheck", "--outDir", out, path.join(root, "web/src/facade-envelope.ts"), path.join(root, "web/src/techdraw-facade.ts"), path.join(root, "web/src/techdraw-save-facade.ts"), path.join(root, "web/src/techdraw-native-facade.ts"), ], { cwd: root, stdio: "pipe" }); const facade = await import(pathToFileURL(path.join(out, "techdraw-facade.js"))); const { FacadeSerialQueue } = await import(pathToFileURL(path.join(out, "facade-envelope.js"))); if (!process.env.FREECAD_TECHDRAW_BUSINESS_BUILD) { const manifest = JSON.parse(fs.readFileSync(path.join(root, "web/public/modules/manifest.json"), "utf8")); const workers = Object.values(manifest.modules ?? manifest) .flatMap(module => Object.values(module?.workers ?? {})) .filter(worker => worker.runtimeUrl?.includes("FreeCADWebTechDrawRuntimeProbe")); assert.ok(workers.length > 0, "Published TechDraw runtime must have manifest routes"); const hashes = new Map(); for (const worker of workers) { for (const [key, url] of [["runtime", worker.runtimeUrl], ["wasm", worker.wasmUrl], ["pthreadWorker", worker.pthreadWorkerUrl]]) { assert.equal(typeof url, "string", `Published TechDraw ${key} URL is required`); if (!hashes.has(url)) { hashes.set(url, createHash("sha256").update(fs.readFileSync(path.join(root, "web/public", url))).digest("hex")); } assert.equal(hashes.get(url), worker.hashes?.[key], `Published TechDraw ${key} must match its manifest`); } } } const { kernel } = await loadWasmProbe(process.env.FREECAD_TECHDRAW_BUSINESS_BUILD ?? path.join(root, "web/public/kernel"), "FreeCADWebTechDrawRuntimeProbe"); const queue = new FacadeSerialQueue(); let sequence = 0; const payload = (documentId, box) => ({ schema: facade.TECHDRAW_SCHEMA, module: "TechDraw", action: "create/recompute", documentId, ...(box ? { box } : {}) }); const call = p => facade.dispatchTechDraw(queue, facade.createTechDrawRequest(`drawing-${++sequence}`, p), kernel); const native = p => JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: facade.TECHDRAW_COMMAND, args: p })])); const dispatch = args => JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: facade.TECHDRAW_COMMAND, args })])); if (ids.includes(capabilityId)) { for (const args of [null, [], "invalid", {}]) { const actual = dispatch(args); assert.equal(actual.ok, false); assert.equal(typeof actual.error, "string"); assert.ok(actual.error.length > 0); cases.push({ input: { cmd: facade.TECHDRAW_COMMAND, args }, actual, assertions: [{ name: "native dispatcher rejects invalid drawing requests", passed: actual.ok === false }] }); } const invalidInitial = payload("InvalidInitial", { length: 0, width: 1, height: 1 }); const rejectedInitial = native(invalidInitial); assert.equal(rejectedInitial.ok, false); cases.push({ input: invalidInitial, actual: rejectedInitial, assertions: [{ name: "native creation rejects zero dimensions", passed: rejectedInitial.ok === false }] }); } const drawing = async (documentId, box, volume) => { const input = payload(documentId, box); const response = await call(input); assert.equal(response.kind, "result", JSON.stringify(response)); const r = response.result; assert.equal(r.cmd, facade.TECHDRAW_COMMAND); assert.equal(r.native, true); assert.equal(r.status, "exposed"); assert.equal(r.document.name, documentId); assert.ok(Math.abs(r.volume - volume) < 1e-7, `actual native volume ${r.volume}, expected ${volume}`); assert.deepEqual(r.box, box); assert.equal(r.document.objectCount, 4); assert.deepEqual(r.pageSize, [297, 210]); assert.equal(r.edgeCount, 4); assert.ok(Math.abs(r.bounds[2] - r.bounds[0] - box.length) < 1e-7); assert.ok(Math.abs(r.bounds[3] - r.bounds[1] - box.height) < 1e-7); assert.ok(r.svg.includes(" io.dispatchTechDrawNative(queue, io.createTechDrawNativeRequest(`export-${++sequence}`, input), kernel); const outputs = []; for (const [documentId, box] of [ ["ExportDrawingA", { length: 31, width: 17, height: 11 }], ["ExportDrawingB", { length: 43, width: 9, height: 23 }], ["ExportDrawingA", { length: 19, width: 8, height: 7 }], ]) { const current = await drawing(documentId, box, box.length * box.width * box.height); const input = { ...io.TECHDRAW_NATIVE_FIXTURE, ...box, documentId, page: "Page", view: "View" }; const response = await exportDrawing(input); const retained = await call(payload(documentId)); const result = response.kind === "result" ? response.result : undefined; const assertions = [ { name: "Export accepts the selected current document and variable geometry", passed: response.kind === "result" }, { name: "Export returns inspectable SVG geometry", passed: typeof result?.svg === "string" && result.svg.includes(" typeof r?.svg === "string") && new Set(outputs.map(r => r?.svg)).size === 3 }, { name: "DXF changes with document geometry and subsequent edits", passed: outputs.every(r => typeof r?.dxf === "string") && new Set(outputs.map(r => r?.dxf)).size === 3 }, ] }); const exportFailures = importExportCases.flatMap(entry => { const failed = entry.assertions.filter(check => !check.passed); if (!failed.length) return []; const response = entry.actual.response; return [{ input: entry.input, failed, ...(response?.kind === "error" ? { error: response.error } : {}) }]; }); assert.equal(exportFailures.length, 0, `TechDraw current-document export failures: ${JSON.stringify(exportFailures)}`); } catch (error) { exportFailure = String(error.stack ?? error); exitCode = 1; } } if (ids.includes(saveId)) { const save = await import(pathToFileURL(path.join(out, "techdraw-save-facade.js"))); for (const box of [{ length: 31, width: 17, height: 11 }, { length: 43, width: 9, height: 23 }]) { const documentId = "SaveCurrentDrawing"; const current = await drawing(documentId, box, box.length * box.width * box.height); const input = { ...save.TECHDRAW_SAVE_FIXTURE, documentId }; const response = await save.dispatchTechDrawSave(queue, save.createTechDrawSaveRequest(`save-${++sequence}`, input), kernel); const result = response.kind === "result" ? response.result : undefined; const assertions = [ { name: "Native save/reopen succeeds", passed: response.kind === "result" }, { name: "Reopened document retains all current drawing objects", actual: result?.objectCount ?? null, expected: current.document.objectCount, passed: result?.objectCount === current.document.objectCount }, { name: "Native saved FCStd bytes are available", actual: result?.bytes?.byteLength ?? 0, passed: result?.bytes instanceof ArrayBuffer && result.bytes.byteLength === result.size && result.size > 0 }, ]; saveCases.push({ input: { drawing: payload(documentId, box), save: input }, actual: { current, response: result ? { ...result, bytes: undefined, byteLength: result.bytes?.byteLength ?? 0 } : response }, assertions }); } assert.ok(saveCases.every(entry => entry.assertions.every(check => check.passed)), "Current-document TechDraw persistence contract failed; fixed sample persistence cannot satisfy this business"); } if (ids.includes(imageId)) { const image = args => { const request = { cmd: "techdraw.desktopCommand", args: { tier: "tier-3-stateful", records: [ { apiId: imageId, name: "TechDraw_Image", family: "desktop-command", args }, ] } }; const response = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify(request)])); assert.equal(response.ok, true, JSON.stringify(response)); return response.records[0]; }; // Generate different valid bitmaps, so native retention is checked against variable input. const bitmap = color => { const bytes = Buffer.alloc(58); bytes.write("BM"); bytes.writeUInt32LE(58, 2); bytes.writeUInt32LE(54, 10); bytes.writeUInt32LE(40, 14); bytes.writeInt32LE(1, 18); bytes.writeInt32LE(1, 22); bytes.writeUInt16LE(1, 26); bytes.writeUInt16LE(24, 28); bytes.writeUInt32LE(4, 34); bytes.set(color, 54); return bytes.toString("base64"); }; for (const [document, color] of [["DrawingA", [0, 0, 255]], ["DrawingB", [0, 255, 0]]]) { const input = { document, page: "Page", owner: "View", imageBase64: bitmap(color) }; const actual = image(input); assert.ok(["success", "exposed"].includes(actual.status), JSON.stringify(actual)); assert.equal(actual.code, "native_image_created"); assert.equal(actual.result.document, document); assert.equal(actual.result.owner, "View"); assert.equal(actual.result.imageBase64, input.imageBase64); const retained = await call(payload(document)); assert.equal(retained.result.document.objectCount, 5); imageCases.push({ input, actual, assertions: [ { name: "Native DrawViewImage retains uploaded bitmap and owner in selected document", passed: true }, { name: "Subsequent native recompute retains image object", passed: true }, ] }); } for (const source of [{}, { imageBase64: bitmap([255, 0, 0]) }]) { const input = { document: "DrawingA", page: "Page", action: "cancel", ...source }; const actual = image(input); const retained = await call(payload("DrawingA")); const assertions = [ { name: "Image cancellation returns a native cancelled result", passed: actual.status === "cancelled" }, { name: "Cancellation preserves native objects and drawing", passed: retained.kind === "result" && retained.result.document.objectCount === 5 && retained.result.svg === edited.svg }, ]; imageCases.push({ input, actual, assertions }); assert.ok(assertions.every(check => check.passed), JSON.stringify({ actual, retained })); } for (const imageBase64 of [Buffer.from("not an image").toString("base64"), "Qk0=", "not base64!"]) { const input = { document: "DrawingA", page: "Page", imageBase64 }; const actual = image(input); assert.equal(actual.status, "error", JSON.stringify(actual)); const retained = await call(payload("DrawingA")); assert.equal(retained.result.document.objectCount, 5); assert.equal(retained.result.svg, edited.svg); imageCases.push({ input, actual, assertions: [ { name: "Invalid or truncated image rejected without changing native document or drawing", passed: true }, ] }); } let origin = process.env.FREECAD_BROWSER_ORIGIN; if (!origin) { server = spawn(path.join(root, "web/node_modules/.bin/vite"), ["--host", "127.0.0.1", "--port", "0"], { cwd: path.join(root, "web"), stdio: ["ignore", "pipe", "pipe"] }); serverClosed = once(server, "exit"); origin = await new Promise((resolve, reject) => { let output = ""; const timer = setTimeout(() => reject(new Error("TechDraw browser server startup timed out")), 30000); server.once("error", error => { clearTimeout(timer); reject(error); }); server.once("exit", code => { clearTimeout(timer); reject(new Error(`Browser server exited: ${code}`)); }); server.stdout.on("data", chunk => { output += chunk.toString(); const match = output.match(/http:\/\/127\.0\.0\.1:\d+/); if (match) { clearTimeout(timer); resolve(match[0]); } }); server.stderr.on("data", chunk => process.stderr.write(chunk)); }); } const browser = JSON.parse(execFileSync(process.execPath, [path.join(root, "tools/techdraw-browser-cases.mjs")], { cwd: root, encoding: "utf8", timeout: 180000, env: { ...process.env, FREECAD_BROWSER_ORIGIN: origin, FREECAD_TECHDRAW_BROWSER_CASE: "image" }, })); assert.equal(browser.schema, "freecad-web-business-cases/v1"); const imageResult = browser.results.find(result => result.capabilityId === imageId); assert.equal(imageResult?.status, "passed", JSON.stringify(browser)); assert.ok(imageResult.cases.length > 0); imageCases.push(...imageResult.cases); } report = { schema: "freecad-web-business-cases/v1", results: ids.map(id => id === imageId ? { capabilityId: id, status: "passed", cases: imageCases } : id === saveId ? { capabilityId: id, status: "passed", cases: saveCases } : id === importExportId || id === dxfExportId ? { capabilityId: id, status: exportFailure ? "failed" : "passed", cases: importExportCases, ...(exportFailure ? { error: exportFailure } : {}) } : id === capabilityId ? { capabilityId: id, status: "passed", resolvedFailureKeys: ["1f9e4df157beb6ac50aa", "ef9280330f04cffee8a1"], resolvedIssueIds: ["runtime:4edceb04f9efe42f1a2c", "runtime:b323c97a70cc280a9aeb"], cases } : { capabilityId: id, status: "not_implemented", cases: [] }) }; } catch (error) { exitCode = 1; const diagnostic = error instanceof Error ? error.stack ?? error.message : String(error); // execFileSync captures compiler and browser diagnostics in pipe buffers. // Include them in the business report when the child exits before returning JSON. const childOutput = error && typeof error === "object" ? ["stdout", "stderr"].flatMap(stream => { const value = error[stream]; if (typeof value !== "string" && !Buffer.isBuffer(value)) return []; const output = value.toString().trim(); return output ? [`${stream}:\n${output}`] : []; }) : []; const failure = [diagnostic, ...childOutput].join("\n"); report = { schema: "freecad-web-business-cases/v1", results: ids.map(id => ({ capabilityId: id, status: [capabilityId, imageId, saveId, importExportId, dxfExportId].includes(id) ? "failed" : "not_implemented", cases: id === imageId ? imageCases : id === saveId ? saveCases : id === importExportId || id === dxfExportId ? importExportCases : id === capabilityId ? cases : [], ...([capabilityId, imageId, saveId, importExportId, dxfExportId].includes(id) ? { error: failure } : {}), })) }; } finally { if (server && server.exitCode === null) { server.kill("SIGTERM"); await serverClosed; } fs.rmSync(out, { recursive: true, force: true }); } await writeBusinessReport(report); // All synchronous native work and cleanup is complete; the Emscripten pthread // pool otherwise keeps this one-shot case process alive until the runner timeout. process.exit(exitCode);