Files
FreeCADWasmDev/tools/techdraw-business-cases.mjs
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

329 lines
20 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, 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("<path"));
cases.push({ input, actual: r, assertions: [
{ name: `native solid volume ${volume}`, actual: r.volume, expected: volume, passed: Math.abs(r.volume - volume) < 1e-7 },
{ name: "DrawPage and DrawViewPart retained with four projected edges at requested dimensions", passed: true },
] });
return r;
};
let edited;
if (ids.includes(capabilityId) || ids.includes(imageId)) {
const first = await drawing("DrawingA", { length: 40, width: 24, height: 18 }, 17280);
const second = await drawing("DrawingB", { length: 12, width: 7, height: 9 }, 756);
assert.notEqual(first.svg, second.svg);
edited = await drawing("DrawingA", { length: 55, width: 8, height: 13 }, 5720);
assert.notEqual(edited.svg, first.svg);
const invalid = payload("DrawingA", { length: -1, width: 8, height: 13 });
assert.equal((await call(invalid)).kind, "error");
assert.equal(native(invalid).ok, false);
const geometricFailure = native(payload("DrawingA", { length: 1e-12, width: 8, height: 13 }));
assert.equal(geometricFailure.ok, false);
assert.notEqual(geometricFailure.rollbackFailed, true);
assert.equal(native(payload("NeverCreated", { length: "bad", width: 8, height: 13 })).ok, false);
assert.equal((await call(payload("NeverCreated"))).kind, "error");
assert.equal(native(payload("bad/name", { length: 1, width: 1, height: 1 })).ok, false);
const after = await call(payload("DrawingA"));
assert.equal(after.kind, "result", JSON.stringify(after));
assert.deepEqual(after.result.box, edited.box);
assert.equal(after.result.svg, edited.svg);
const isolated = await call(payload("DrawingB"));
assert.equal(isolated.kind, "result");
assert.deepEqual(isolated.result.box, second.box);
assert.equal(isolated.result.svg, second.svg);
cases.push({ input: { invalid, geometricFailure: payload("DrawingA", { length: 1e-12, width: 8, height: 13 }), recompute: payload("DrawingA"), isolated: payload("DrawingB") },
actual: { geometricFailure, recomputed: after.result, isolated: isolated.result }, assertions: [
{ name: "facade and native invalid dimensions leave previous drawing unchanged", passed: true },
{ name: "failed native geometry recompute rolls back the edited document", passed: true },
{ name: "recompute edits existing objects without accumulation", passed: true },
{ name: "independent document drawings preserve geometry and native SVG", passed: true },
{ name: "Node ESM loader reaches real TechDraw creation, recompute and SVG output", passed: true },
] });
}
if (ids.includes(importExportId) || ids.includes(dxfExportId)) {
try {
const io = await import(pathToFileURL(path.join(out, "techdraw-native-facade.js")));
const exportDrawing = input => 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("<path") },
{ name: "Export returns inspectable DXF entities", passed: typeof result?.dxf === "string" && /\bENTITIES\b/.test(result.dxf) },
{ name: "Export preserves the current drawing", passed: retained.kind === "result"
&& retained.result.svg === current.svg && retained.result.document.objectCount === current.document.objectCount },
];
importExportCases.push({ input: { drawing: payload(documentId, box), export: input }, actual: { response, retained }, assertions });
outputs.push(result);
}
for (const documentId of ["MissingExportDocument", "bad/name"]) {
const input = { ...io.TECHDRAW_NATIVE_FIXTURE, documentId, page: "Page", view: "View" };
const response = await exportDrawing(input);
const retained = await call(payload("ExportDrawingB"));
importExportCases.push({ input, actual: { response, retained }, assertions: [
{ name: "Invalid document export is rejected", passed: response.kind === "error" },
{ name: "Rejected export preserves the other document", passed: retained.kind === "result"
&& retained.result.box.length === 43 && retained.result.box.width === 9 && retained.result.box.height === 23 },
] });
}
importExportCases.push({ input: { documents: ["ExportDrawingA", "ExportDrawingB", "ExportDrawingA"], operation: "compare exports before and after edit" },
actual: { outputs }, assertions: [
{ name: "SVG changes with document geometry and subsequent edits", passed: outputs.every(r => 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);