import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; 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 supported = ["Spreadsheet:spreadsheet.importExportCsv", "business-format:Spreadsheet:import:.csv", "Spreadsheet:spreadsheet.createRecompute", "Spreadsheet:spreadsheet.saveReopen"]; const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify(supported)); const directory = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-spreadsheet-business-")); const cases = []; const timing = []; let stage = "compile-facade"; let started = performance.now(); let error; const next = name => { timing.push({ stage, seconds: Number(((performance.now() - started) / 1000).toFixed(4)) }); stage = name; started = performance.now(); }; const parseCsv = (text, delimiter) => JSON.parse(execFileSync("python3", ["-c", "import csv,io,json,sys; v=json.load(sys.stdin); print(json.dumps(list(csv.reader(io.StringIO(v['text']),delimiter=v['delimiter'],escapechar='\\\\'))))"], { input: JSON.stringify({ text, delimiter }), encoding: "utf8", timeout: 5000 })); try { assert.ok(Array.isArray(ids) && ids.length && new Set(ids).size === ids.length && ids.every(id => supported.includes(id))); execFileSync(path.join(root, "web/node_modules/.bin/tsc"), ["--target", "ES2022", "--module", "ESNext", "--moduleResolution", "Bundler", "--strict", "--skipLibCheck", "--outDir", directory, path.join(root, "web/src/spreadsheet-facade.ts"), path.join(root, "web/src/spreadsheet-save-facade.ts")], { cwd: root, stdio: "pipe", timeout: 30000 }); const facade = await import(pathToFileURL(path.join(directory, "spreadsheet-facade.js"))); const { FacadeSerialQueue } = await import(pathToFileURL(path.join(directory, "facade-envelope.js"))); next("load-existing-wasm"); const { kernel } = await loadWasmProbe(process.env.FREECAD_SPREADSHEET_BUSINESS_BUILD ?? path.join(root, "web/public/kernel"), "FreeCADWebSpreadsheetRuntimeProbe"); next("native-csv-roundtrips"); const queue = new FacadeSerialQueue(); let sequence = 0; if (ids.includes("Spreadsheet:spreadsheet.saveReopen")) { next("native-save-reopen"); const save = await import(pathToFileURL(path.join(directory, "spreadsheet-save-facade.js"))); const persist = input => save.dispatchSpreadsheetSave(queue, save.createSpreadsheetSaveRequest(`save-${++sequence}`, input), kernel); for (const sample of [ { a1: "13", b1: "=A1*3" }, { a1: "7.5", b1: "=A1+2" }, { a1: "'Panel A", b1: "'left right" }, ]) { const input = { ...save.SPREADSHEET_SAVE_FIXTURE, path: `/tmp/SpreadsheetBusiness${sequence}.FCStd`, ...sample }; const response = await persist(input); assert.equal(response.kind, "result", JSON.stringify(response)); const { bytes, ...actual } = response.result; assert.equal(actual.cellA1, input.a1); assert.equal(input.b1.startsWith("=") ? actual.cellB1.replace(/\s/g, "") : actual.cellB1, input.b1); assert.equal(actual.path, input.path); assert.equal(bytes.byteLength, actual.size); assert.deepEqual([...new Uint8Array(bytes).slice(0, 4)], [80, 75, 3, 4]); const artifact = path.join(directory, path.basename(input.path)); fs.writeFileSync(artifact, new Uint8Array(bytes)); const archive = JSON.parse(execFileSync("python3", ["-c", "import json,sys,zipfile,xml.etree.ElementTree as E; z=zipfile.ZipFile(sys.argv[1]); r=E.fromstring(z.read('Document.xml')); print(json.dumps([e.attrib for e in r.iter('Cell')]))", artifact], { encoding: "utf8", timeout: 5000 })); assert.ok(archive.some(cell => cell.address === "A1" && cell.content === input.a1), JSON.stringify(archive)); kernel.FS.unlink(input.path); assert.equal(kernel.FS.analyzePath(input.path).exists, false); cases.push({ input, actual, assertions: [{ name: "variable cells and whitespace survive native FCStd restore and exported ZIP contains supplied A1", passed: true }] }); } kernel.FS.writeFile("/tmp/SpreadsheetBlockedParent", "not a directory"); for (const invalid of [{ path: "/tmp/SpreadsheetBlockedParent/book.FCStd" }, { path: "/tmp/../bad.FCStd" }, { a1: "" }, { b1: "=A1+" }]) { const input = { ...save.SPREADSHEET_SAVE_FIXTURE, a1: "9", b1: "=A1*4", ...invalid }; const response = await persist(input); assert.equal(response.kind, "error", JSON.stringify(response)); const native = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: save.SPREADSHEET_SAVE_COMMAND, args: input })])); assert.equal(native.ok, false, JSON.stringify(native)); assert.equal(native.resourcesReleased, true); cases.push({ input, actual: { response, native }, assertions: [{ name: "invalid path or cells fail and release temporary documents", passed: true }] }); } kernel.FS.unlink("/tmp/SpreadsheetBlockedParent"); const recoveryInput = { ...save.SPREADSHEET_SAVE_FIXTURE, path: "/tmp/SpreadsheetRecovered.FCStd", a1: "19", b1: "=A1*5" }; const recovery = await persist(recoveryInput); assert.equal(recovery.kind, "result", JSON.stringify(recovery)); assert.equal(recovery.result.cellA1, "19"); assert.equal(recovery.result.cellB1.replace(/\s/g, ""), "=A1*5"); kernel.FS.unlink(recoveryInput.path); const { bytes, ...actual } = recovery.result; cases.push({ input: recoveryInput, actual, assertions: [{ name: "same native instance saves and restores new input after failure", passed: true }] }); } if (ids.includes("Spreadsheet:spreadsheet.createRecompute")) { next("native-create-recompute"); const create = await queue.dispatch(facade.createSpreadsheetCreateRequest(`create-${++sequence}`, { ...facade.SPREADSHEET_CREATE_FIXTURE, a1: "21", b1: "=A1*2" }), (payload, signal) => facade.handleSpreadsheetCreate(payload, signal, kernel)); assert.equal(create.kind, "result", JSON.stringify(create)); assert.equal(create.result.objectType, "Spreadsheet::Sheet"); assert.equal(create.result.objectCount, 1); assert.equal(create.result.recomputeCount, 1); assert.equal(create.result.cellA1, "21"); assert.equal(create.result.cellB1, "=A1*2"); assert.equal(create.result.recomputed, true); assert.equal(create.result.resourcesReleased, true); cases.push({ input: facade.SPREADSHEET_CREATE_FIXTURE, actual: create.result, assertions: [{ name: "native Sheet creation and recompute honor supplied cell values", passed: true }] }); } if (ids.some(id => id.includes("importExportCsv") || id === "business-format:Spreadsheet:import:.csv")) { next("native-csv-roundtrips"); const call = input => queue.dispatch(facade.createSpreadsheetCsvRequest(`csv-${++sequence}`, input), (payload, signal) => facade.handleSpreadsheetCsv(payload, signal, kernel)); const samples = [ { csv: "Name,Quantity,Price\nBracket,2,12.5\nPlate,7,3.25\n", delimiter: ",", usedRange: "A1:C3" }, { csv: 'Name;Description\nPlate;"left;right"\nAngle;trimmed\n', delimiter: ";", usedRange: "A1:B3" }, { csv: "Part\tQuantity\nPanel\t4\n", delimiter: "\t", usedRange: "A1:B2" }, ]; for (const sample of samples) { const input = { ...facade.SPREADSHEET_CSV_FIXTURE, csv: sample.csv, delimiter: sample.delimiter }; const response = await call(input); assert.equal(response.kind, "result", JSON.stringify(response)); const actual = response.result; assert.equal(actual.objectType, "Spreadsheet::Sheet"); assert.equal(actual.usedRange, sample.usedRange); assert.equal(actual.importBytes, Buffer.byteLength(input.csv)); assert.equal(actual.exportBytes, Buffer.byteLength(actual.csv)); assert.equal(actual.recomputed, true); assert.equal(actual.resourcesReleased, true); assert.deepEqual(parseCsv(actual.csv, sample.delimiter), parseCsv(sample.csv, sample.delimiter)); const repeated = await call({ ...input, csv: actual.csv }); assert.equal(repeated.kind, "result", JSON.stringify(repeated)); assert.deepEqual(parseCsv(repeated.result.csv, sample.delimiter), parseCsv(sample.csv, sample.delimiter)); cases.push({ input, actual, assertions: [ { name: "provided CSV cells survive native Sheet import, recompute, export and reimport", passed: true }, { name: "native used range and artifact byte lengths match the supplied rows", passed: true }, { name: "temporary documents and CSV files are released", passed: !kernel.FS.analyzePath("/tmp/FreeCADWebSpreadsheetImport.csv").exists && !kernel.FS.analyzePath("/tmp/FreeCADWebSpreadsheetExport.csv").exists }, ] }); } next("invalid-input-and-recovery"); for (const invalid of [{ csv: "" }, { delimiter: "::" }, { quote: "" }, { escape: "" }]) { const input = { ...facade.SPREADSHEET_CSV_FIXTURE, ...invalid }; const response = await call(input); assert.equal(response.kind, "error", JSON.stringify(response)); const native = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: facade.SPREADSHEET_CSV_COMMAND, args: input })])); assert.equal(native.ok, false, JSON.stringify(native)); assert.equal(native.resourcesReleased, true); cases.push({ input, actual: { response, native }, assertions: [{ name: "Facade and native CSV entry reject invalid parameters without leaked documents", passed: true }] }); } const recovered = await call({ ...facade.SPREADSHEET_CSV_FIXTURE, csv: "Status,Count\nRecovered,9\n" }); assert.equal(recovered.kind, "result", JSON.stringify(recovered)); assert.deepEqual(parseCsv(recovered.result.csv, ","), [["Status", "Count"], ["Recovered", "9"]]); cases.push({ input: { csv: "Status,Count\nRecovered,9\n" }, actual: recovered.result, assertions: [{ name: "valid input works after rejected requests in the same native instance", passed: true }] }); } assert.ok(cases.length && cases.every(entry => entry.assertions.every(check => check.passed))); } catch (failure) { error = `${stage}: ${failure.stack ?? failure}${failure.stderr ? `\n${failure.stderr}` : ""}`; } finally { next("cleanup"); fs.rmSync(directory, { recursive: true, force: true }); next("complete"); } await writeBusinessReport({ schema: "freecad-web-business-cases/v1", timing, results: ids.map(capabilityId => ({ capabilityId, status: error ? "failed" : "passed", cases, ...(error ? { error } : {}), })) }); process.exit(error ? 1 : 0);