158 lines
10 KiB
JavaScript
158 lines
10 KiB
JavaScript
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);
|