Files
FreeCADWasmDev/tools/run_p6_fem_generic_object_module_batch_gate.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

106 lines
10 KiB
JavaScript

#!/usr/bin/env node
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Worker } from "node:worker_threads";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const root = "/home/mes123456/FreeCADWasmDev/working/FreeCAD";
const build = path.resolve(process.argv[2] ?? path.join(root, "web/public/kernel"));
const batch = JSON.parse(fs.readFileSync(path.join(root, "docs/current/module-batch-manifest.json"), "utf8"));
if (batch.status !== "ready" || batch.taskId !== "FW-P6-FPA-Y" || batch.selected?.module !== "Fem" || batch.selected?.tier !== "tier-1-readonly" || batch.selected?.family !== "generic-object" || batch.records.length !== batch.selected?.recordCount || batch.records.length < batch.minimumRecords) throw new Error("unexpected Fem generic-object module batch manifest");
const records = batch.records.map(([apiId, name, disposition, sourcePath, sourceLine]) => ({ apiId, name, family: batch.selected.family, kind: apiId.split(":")[2], sourcePath, sourceLine, disposition }));
const hash = (value) => crypto.createHash("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest("hex");
const fileHash = (file) => hash(fs.readFileSync(file));
const terminal = new Set(["exposed", "restricted", "nonportable", "not_implemented"]);
const guiBoundaries = new Set([
"createLabel.get_position", "FemWorkbench", "CommandManager.IsActive", "FemExamples", "HighlightContext", "FemMigrateGui", "BoundarySelector",
"GeometryElementsSelection.get_references", "GeometryElementsSelection.get_item_text", "GeometryElementsSelection.get_allitems_text", "VtkTableModel",
"VPSolverCalculiX", "VPSolverCcxTools", "VPSolverElmer",
]);
const meshFixtures = new Set(["FemMesh.getNodeById", "FemMesh.getNodesByVertex", "FemMesh.getElementNodes", "FemMesh.getNodeElements"]);
const output = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-fem-generic-object-"));
const sourceEvidence = records.map((row) => {
if (!row.sourcePath.startsWith("src/Mod/Fem/") || row.sourcePath.includes("..")) throw new Error(`invalid Fem source path for ${row.apiId}`);
const source = path.join(root, "freecad-source", row.sourcePath);
const declaration = fs.readFileSync(source, "utf8").split(/\r?\n/)[row.sourceLine - 1] ?? "";
const symbol = row.name.split(".").at(-1);
const marker = row.kind === "class" ? `class ${symbol}` : `def ${symbol}(`;
assert.ok(declaration.includes(marker), `source declaration mismatch for ${row.apiId}`);
return { apiId: row.apiId, path: row.sourcePath, line: row.sourceLine, declaration: declaration.trim(), sha256: fileHash(source) };
});
execFileSync(path.join(root, "web/node_modules/.bin/tsc"), ["--target", "ES2022", "--module", "ESNext", "--moduleResolution", "Bundler", "--strict", "--skipLibCheck", "--outDir", output, "--declaration", "false", "--noEmit", "false", path.join(root, "web/src/facade-envelope.ts"), path.join(root, "web/src/fem-python-family-facade.ts")], { cwd: root, stdio: "inherit" });
const facade = await import(pathToFileURL(path.join(output, "fem-python-family-facade.js")));
const envelope = await import(pathToFileURL(path.join(output, "facade-envelope.js")));
const worker = new Worker(path.join(root, "tools/fem-python-family-batch-smoke-worker.mjs"), { workerData: { buildDirectory: build, facadePath: pathToFileURL(path.join(output, "fem-python-family-facade.js")).href, envelopePath: pathToFileURL(path.join(output, "facade-envelope.js")).href } });
const next = () => new Promise((resolve, reject) => { const onMessage = (value) => { worker.off("error", onError); resolve(value); }; const onError = (error) => { worker.off("message", onMessage); reject(error); }; worker.once("message", onMessage); worker.once("error", onError); });
const request = (requestId, rows = records) => facade.createFemPythonFamilyRequest(requestId, rows, "tier-1-readonly");
const ready = await next();
worker.postMessage(request("fem-generic-object-success")); const success = await next();
worker.postMessage(request("fem-generic-object-repeat")); const repeat = await next();
worker.postMessage(request("fem-generic-object-cross", [{ ...records[0], apiId: "py:BIM:class:Binding" }])); const crossModule = await next();
const malformedRequest = request("fem-generic-object-invalid"); worker.postMessage({ ...malformedRequest, payload: { ...malformedRequest.payload, tier: "tier-unsupported" } }); const malformed = await next();
const cancelRequest = request("fem-generic-object-cancel"); worker.postMessage(cancelRequest); worker.postMessage(envelope.createCancel("fem-generic-object-cancel-control", cancelRequest.requestId, "fem-python-family")); const cancelled = await next();
worker.postMessage({ kind: "reset", requestId: "fem-generic-object-reset" }); const reset = await next();
await worker.terminate();
if (ready.kind !== "ready" || success.kind !== "result" || repeat.kind !== "result" || crossModule.kind !== "result" || malformed.kind !== "error" || cancelled.kind !== "error" || reset.requestId !== "fem-generic-object-reset") throw new Error(JSON.stringify({ ready, success, repeat, crossModule, malformed, cancelled, reset }));
const result = success.result;
if (result.module !== "Fem" || result.tier !== "tier-1-readonly" || result.count !== records.length || result.records.length !== records.length || result.resourcesReleased !== true) throw new Error("Fem generic-object result is incomplete");
for (const row of result.records) {
if (!terminal.has(row.status) || row.native !== true || row.resourcesReleased !== true || typeof row.sourceEntry !== "string" || typeof row.sourceLine !== "number" || row.sourceVerified !== true || typeof row.sourceRevision !== "string" || typeof row.parameterSchema !== "string" || typeof row.returnSchema !== "string") throw new Error(`Fem native record schema is incomplete for ${row.name}`);
if (guiBoundaries.has(row.name) && (row.status !== "restricted" || row.code !== "gui_dependency_unavailable")) throw new Error(`Fem GUI boundary mismatch for ${row.name}`);
if (meshFixtures.has(row.name) && (row.status !== "exposed" || !row.result || row.code !== "native_fixture")) throw new Error(`Fem mesh fixture mismatch for ${row.name}`);
}
if (JSON.stringify(result) !== JSON.stringify(repeat.result)) throw new Error("Fem generic-object replay changed native result");
if (crossModule.result.records?.[0]?.status !== "not_implemented" || crossModule.result.records?.[0]?.code !== "unmatched_source_record") throw new Error("Fem cross-module boundary failed");
if (malformed.error.code !== "invalid_request" || cancelled.error.code !== "cancelled") throw new Error("Fem error/cancel boundary failed");
const evidencePath = `docs/p6/fem-generic-object-module-batch-${batch.batchId}-runtime.json`;
const manifestPath = path.join(root, "docs/p6/python-api-manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const byId = new Map(result.records.map((row) => [row.apiId, row]));
for (const row of manifest.records) {
const native = byId.get(row.id);
if (!native) continue;
row.disposition = native.status;
row.dispositionEvidence = [evidencePath];
row.dispositionReason = native.message;
}
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const dispositionCounts = Object.fromEntries([...terminal].map((status) => [status, result.records.filter((row) => row.status === status).length]));
const trace = { ready, success, repeat, crossModule, malformed, cancelled, reset };
const report = {
schema: "freecad-web-fem-generic-object-module-batch-runtime/v1",
taskId: "FW-P6-FPA-Y",
batchId: batch.batchId,
module: "Fem",
tier: "tier-1-readonly",
family: "generic-object",
status: "pass_with_boundaries",
sourceRevision: result.records[0]?.sourceRevision,
dispatcher: "fc_web_dispatch_json",
route: { command: "fem.pythonFamilyBatch", native: "freecad-source/src/WebAssembly/FreeCADCoreProbe/FemGenericFamilyProbe.cpp:fc_web_dispatch_json", facade: "web/src/fem-python-family-facade.ts", worker: "web/src/fem.worker.ts" },
input: { cmd: "fem.pythonFamilyBatch", args: { tier: "tier-1-readonly", records } },
inputSha256: hash(records),
result,
resultSha256: hash(result),
replay: { equal: true, resultSha256: hash(repeat.result) },
facadeWorker: { ready, success, repeat, crossModule, malformed, cancelled, reset, traceSha256: hash(trace) },
dispositionCounts,
records: result.records.map((row) => ({ apiId: row.apiId, name: row.name, family: row.family, status: row.status, code: row.code, message: row.message, sourceEntry: row.sourceEntry, sourceLine: row.sourceLine, sourceVerified: row.sourceVerified, sourceRevision: row.sourceRevision, result: row.result, evidenceSha256: hash(row) })),
sourceEvidence,
resources: { resourcesReleased: result.resourcesReleased, documentMutation: false },
artifacts: { loader: { path: path.relative(root, path.join(build, "FreeCADWebFemGenericFamilyProbe.js")), sha256: fileHash(path.join(build, "FreeCADWebFemGenericFamilyProbe.js")) }, wasm: { path: path.relative(root, path.join(build, "FreeCADWebFemGenericFamilyProbe.wasm")), sha256: fileHash(path.join(build, "FreeCADWebFemGenericFamilyProbe.wasm")) } },
boundaries: ["FemMesh and FemPost records are exposed only after the compiled FreeCAD/WASM fixture executes the selected official API methods.", "FreeCADGui, Qt, Coin3D, and VTK UI records are explicit restricted boundaries in the headless Fem Web kernel.", "All statuses and typed Fem values originate from compiled FreeCAD through fc_web_dispatch_json; JavaScript only validates and transports the shared batch envelope."],
};
fs.writeFileSync(path.join(root, evidencePath), `${JSON.stringify(report, null, 2)}\n`);
const logPath = `docs/freecad-p6-fem-generic-object-module-batch-${batch.batchId}.log`;
fs.writeFileSync(path.join(root, logPath), `${JSON.stringify({ status: report.status, batchId: report.batchId, count: records.length, dispositionCounts, resultSha256: report.resultSha256 })}\n`);
console.log(JSON.stringify({ status: report.status, batchId: report.batchId, count: records.length, dispositionCounts, resultSha256: report.resultSha256 }));
process.exit(0);