226 lines
11 KiB
JavaScript
226 lines
11 KiB
JavaScript
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { loadWasmProbe } from "./wasm-probe-runtime.mjs";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const capabilityId = "Assembly:assembly.createRecompute";
|
|
const persistenceId = "Assembly:assembly.saveReopen";
|
|
let cases = [];
|
|
let activeId = capabilityId;
|
|
const results = [];
|
|
const nativeDiagnostics = [];
|
|
let kernel;
|
|
let exitCode = 0;
|
|
|
|
function runCase(args, check, cmd = "assembly.createRecompute") {
|
|
const input = { cmd, args };
|
|
const entry = { input, assertions: [] };
|
|
cases.push(entry);
|
|
try {
|
|
entry.actual = JSON.parse(String(kernel.ccall(
|
|
"fc_web_dispatch_json", "string", ["string"], [JSON.stringify(input)],
|
|
)));
|
|
entry.assertions = check(entry.actual).map(([name, passed]) => ({ name, passed: Boolean(passed) }));
|
|
} catch (error) {
|
|
entry.error = String(error.stack ?? error);
|
|
entry.assertions.push({ name: "native request returns valid JSON", passed: false });
|
|
}
|
|
return entry.actual;
|
|
}
|
|
|
|
function successful(args, assemblyCount) {
|
|
// Assembly inherits App::Part's Origin tree. countObjects() includes those
|
|
// native support objects: the current empty assembly occupies nine objects.
|
|
const objectCount = assemblyCount * 9;
|
|
runCase(args, actual => [
|
|
["native Assembly create/recompute succeeds", actual.ok === true
|
|
&& actual.source === "freecad-source" && actual.module === "Assembly"
|
|
&& actual.action === "create/recompute"
|
|
&& actual.schema === "freecad-web-assembly/create-recompute/v1"],
|
|
["requested document retains the expected object count", actual.document?.name === args.documentName
|
|
&& actual.document?.objectCount === objectCount],
|
|
["requested object is a native empty assembly", actual.object?.name === (args.objectName ?? "Assembly")
|
|
&& actual.object?.type === "Assembly::AssemblyObject" && actual.object?.componentCount === 0],
|
|
["native document recompute completes without error", Number.isInteger(actual.recompute?.count)
|
|
&& actual.recompute.count >= 0 && actual.recompute.hasError === 0
|
|
&& Number.isInteger(actual.recompute.solverStatus)],
|
|
]);
|
|
}
|
|
|
|
try {
|
|
const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify([capabilityId, persistenceId]));
|
|
if (!Array.isArray(ids) || !ids.every(id => typeof id === "string")) {
|
|
throw new Error("FREECAD_BUSINESS_IDS must be a JSON array of capability IDs");
|
|
}
|
|
for (const id of new Set(ids)) {
|
|
if (![capabilityId, persistenceId].includes(id)) {
|
|
results.push({ capabilityId: id, status: "not_implemented", cases: [] });
|
|
exitCode = 1;
|
|
}
|
|
}
|
|
if (ids.some(id => [capabilityId, persistenceId].includes(id))) {
|
|
activeId = ids.includes(capabilityId) ? capabilityId : persistenceId;
|
|
({ kernel } = await loadWasmProbe(
|
|
path.join(root, "web/public/kernel"), "FreeCADWebAssemblyProbe",
|
|
{ printErr: text => nativeDiagnostics.push(String(text)) },
|
|
));
|
|
}
|
|
if (ids.includes(capabilityId)) {
|
|
const result = { capabilityId, status: "failed", cases };
|
|
results.push(result);
|
|
const first = {
|
|
schema: "freecad-web-assembly/create-recompute/v1",
|
|
documentName: "AssemblyBusinessPrimary", objectName: "DriveAssembly",
|
|
objectType: "Assembly::AssemblyObject", label: "Drive assembly",
|
|
};
|
|
successful(first, 1);
|
|
successful({ ...first, label: "Drive assembly revised" }, 1);
|
|
successful({ ...first, objectName: "SupportAssembly", label: "Support assembly" }, 2);
|
|
const second = { ...first, documentName: "AssemblyBusinessSecondary", label: "Independent drive" };
|
|
successful(second, 1);
|
|
successful(first, 2);
|
|
for (const args of [
|
|
{ ...first, objectName: "RejectedType", objectType: "App::FeaturePython" },
|
|
{ ...first, objectName: "RejectedLabel", label: 42 },
|
|
{ ...first, objectName: "" },
|
|
{ ...first, documentName: "" },
|
|
{ ...first, objectName: 42 },
|
|
{ ...first, objectName: null },
|
|
{ ...first, objectName: " " },
|
|
{ ...first, documentName: " " },
|
|
{ ...first, objectName: "DriveAssembly\u0000Suffix" },
|
|
{ ...first, documentName: "AssemblyBusinessPrimary\u0000Suffix" },
|
|
]) {
|
|
runCase(args, actual => [
|
|
["native input validation rejects invalid creation", actual.ok === false
|
|
&& actual.code === "assembly_create_failed" && typeof actual.error === "string" && actual.error.length > 0],
|
|
]);
|
|
successful(first, 2);
|
|
}
|
|
successful(second, 1);
|
|
const defaultName = { ...first, documentName: "AssemblyBusinessDefault", label: "Default object name" };
|
|
delete defaultName.objectName;
|
|
successful(defaultName, 1);
|
|
successful(defaultName, 1);
|
|
const passed = cases.length > 0 && cases.every(entry => entry.assertions.length > 0
|
|
&& entry.assertions.every(assertion => assertion.passed));
|
|
result.status = passed ? "passed" : "failed";
|
|
if (passed) result.resolvedFailureKeys = ["161f02f2235e99467738"];
|
|
if (!passed) exitCode = 1;
|
|
}
|
|
if (ids.includes(persistenceId)) {
|
|
activeId = persistenceId;
|
|
cases = [];
|
|
const result = { capabilityId: persistenceId, status: "failed", cases };
|
|
results.push(result);
|
|
const primary = {
|
|
schema: "freecad-web-assembly/create-recompute/v1",
|
|
documentName: "AssemblySavePrimary", objectName: "Drive",
|
|
objectType: "Assembly::AssemblyObject", label: "Drive before revision",
|
|
};
|
|
const support = { ...primary, objectName: "Support", label: "Support frame" };
|
|
const secondary = { ...primary, documentName: "AssemblySaveSecondary", label: "Independent drive" };
|
|
const save = (documentName, filename, expected) => runCase({
|
|
schema: "freecad-web-assembly/save-reopen/v1", module: "Assembly", documentName, path: filename,
|
|
}, actual => {
|
|
const assemblies = actual.objects?.filter(item => item.type === "Assembly::AssemblyObject");
|
|
return [
|
|
["native current-document save and restore succeeds", actual.ok === true
|
|
&& actual.schema === "freecad-web-assembly/save-reopen/v1"
|
|
&& actual.source === "freecad-source" && actual.module === "Assembly"
|
|
&& actual.format === ".fcstd" && actual.roundTrip === true],
|
|
["requested document and output file are used", actual.documentName === documentName
|
|
&& actual.path === filename && Number.isInteger(actual.size) && actual.size > 0],
|
|
["restore creates a separate document", typeof actual.reopenedDocumentName === "string"
|
|
&& actual.reopenedDocumentName.length > 0 && actual.reopenedDocumentName !== documentName],
|
|
["restored tree contains all current assemblies and support objects", actual.objectCount === expected.length * 9
|
|
&& actual.objects?.length === actual.objectCount && assemblies?.length === expected.length
|
|
&& expected.every(item => assemblies?.some(restored => restored.name === item.objectName
|
|
&& restored.label === item.label && typeof restored.typeProperty === "string"))],
|
|
["restored document recomputes", Number.isInteger(actual.recomputeCount) && actual.recomputeCount >= 0],
|
|
];
|
|
}, "assembly.saveReopen");
|
|
successful(primary, 1);
|
|
successful(support, 2);
|
|
successful(secondary, 1);
|
|
const initial = save(primary.documentName, "/tmp/AssemblyBusinessPrimary.FCStd", [primary, support]);
|
|
save(secondary.documentName, "/tmp/AssemblyBusinessSecondary.fcstd", [secondary]);
|
|
const revised = { ...primary, label: "Drive revised for save" };
|
|
successful(revised, 2);
|
|
save(primary.documentName, "/tmp/AssemblyBusinessPrimary.FCStd", [revised, support]);
|
|
// Saving the restored document checks that it retains its original state
|
|
// independently of subsequent changes to the source document.
|
|
if (initial?.ok === true && typeof initial.reopenedDocumentName === "string") {
|
|
save(initial.reopenedDocumentName, "/tmp/AssemblyBusinessRestored.FCStd", [primary, support]);
|
|
}
|
|
for (const args of [
|
|
{ documentName: primary.documentName, path: "/tmp/AssemblyBusinessInvalid.txt" },
|
|
{ documentName: primary.documentName, path: "/outside.FCStd" },
|
|
{ documentName: primary.documentName, path: "/tmp/../outside.FCStd" },
|
|
{ documentName: primary.documentName, path: "/tmp/bad\u0000.FCStd" },
|
|
{ documentName: "AssemblySaveMissing", path: "/tmp/AssemblyBusinessMissing.FCStd" },
|
|
]) {
|
|
runCase(args, actual => [
|
|
["invalid persistence request is rejected", actual.ok === false
|
|
&& actual.code === "assembly_persistence_failed"
|
|
&& typeof actual.error === "string" && actual.error.length > 0],
|
|
], "assembly.saveReopen");
|
|
}
|
|
save(primary.documentName, "/tmp/AssemblyBusinessRecovered.FCStd", [revised, support]);
|
|
save(secondary.documentName, "/tmp/AssemblyBusinessIndependent.FCStd", [secondary]);
|
|
const passed = cases.length > 0 && cases.every(entry => entry.assertions.length > 0
|
|
&& entry.assertions.every(assertion => assertion.passed));
|
|
result.status = passed ? "passed" : "failed";
|
|
if (!passed) exitCode = 1;
|
|
}
|
|
} catch (error) {
|
|
exitCode = 1;
|
|
let result = results.find(item => item.capabilityId === activeId);
|
|
if (!result) {
|
|
result = { capabilityId: activeId, status: "failed", cases };
|
|
results.push(result);
|
|
}
|
|
result.status = "failed";
|
|
result.error = String(error.stack ?? error);
|
|
} finally {
|
|
try {
|
|
kernel?.PThread?.terminateAllThreads?.();
|
|
} catch (error) {
|
|
exitCode = 1;
|
|
for (const result of results) {
|
|
result.status = "failed";
|
|
delete result.resolvedFailureKeys;
|
|
result.cleanupError = String(error);
|
|
}
|
|
}
|
|
for (const result of results) {
|
|
if (result.status === "failed") {
|
|
result.nativeDiagnostics = nativeDiagnostics;
|
|
const firstFailure = result.cases.find(entry => entry.error
|
|
|| entry.assertions.some(assertion => !assertion.passed));
|
|
if (firstFailure) result.firstFailure = {
|
|
input: firstFailure.input,
|
|
actual: firstFailure.actual,
|
|
error: firstFailure.error,
|
|
assertions: firstFailure.assertions.filter(assertion => !assertion.passed),
|
|
};
|
|
// The outer runner retains stderr when a business command exits unsuccessfully.
|
|
await new Promise((resolve, reject) => process.stderr.write(
|
|
JSON.stringify({
|
|
capabilityId: result.capabilityId,
|
|
error: result.error,
|
|
cleanupError: result.cleanupError,
|
|
nativeDiagnostics: nativeDiagnostics.slice(-5),
|
|
firstFailure: result.firstFailure,
|
|
}) + "\n",
|
|
error => error ? reject(error) : resolve(),
|
|
));
|
|
}
|
|
}
|
|
await new Promise((resolve, reject) => process.stdout.write(
|
|
JSON.stringify({ schema: "freecad-web-business-cases/v1", results }) + "\n",
|
|
error => error ? reject(error) : resolve(),
|
|
));
|
|
}
|
|
process.exit(exitCode);
|