329 lines
20 KiB
JavaScript
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);
|