Complete internal submodule smoke closure
This commit is contained in:
74
tools/web/check-wbv2-submodule-smoke-plan.mjs
Normal file
74
tools/web/check-wbv2-submodule-smoke-plan.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const abs = (file) => path.join(root, file);
|
||||
const read = (file) => JSON.parse(fs.readFileSync(abs(file), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(abs(file))).digest("hex");
|
||||
const manifest = read("tests/golden/WBV2/internal-submodule-smoke-manifest.json");
|
||||
const plan = read(manifest.plan.path);
|
||||
const report = read(manifest.report.path);
|
||||
const focused = read(manifest.focusedFixReport.path);
|
||||
const regression = read(manifest.regressionReport.path);
|
||||
|
||||
assert.equal(plan.operation, "ONE_PASS_INTERNAL_SUBMODULE_SMOKE");
|
||||
assert.equal(plan.policy.oneSmokePerSubmodule, true);
|
||||
assert.equal(plan.policy.retryFailedSubmodule, false);
|
||||
assert.equal(plan.cases.length, 101);
|
||||
assert.equal(new Set(plan.cases.map((item) => item.submoduleId)).size, plan.cases.length);
|
||||
assert.equal(new Set(plan.cases.map((item) => item.smokeId)).size, plan.cases.length);
|
||||
assert.ok(plan.cases.every((item) => item.runOnce === true && item.maxAttempts === 1));
|
||||
assert.equal(report.cases.length, plan.cases.length);
|
||||
assert.ok(report.cases.every((item) => item.attempts === 1));
|
||||
assert.equal(report.summary.attempts, plan.cases.length);
|
||||
assert.equal(report.summary.retries, 0);
|
||||
|
||||
const initialPassed = report.cases.filter((item) => item.status === "PASS");
|
||||
const initialFailed = report.cases.filter((item) => item.status !== "PASS");
|
||||
assert.deepEqual(
|
||||
focused.fixes.map((item) => item.submoduleId).sort(),
|
||||
initialFailed.map((item) => item.submoduleId).sort(),
|
||||
"focused verification must contain exactly the initial smoke failures",
|
||||
);
|
||||
assert.ok(focused.fixes.every((item) => item.verificationAttempt === 1));
|
||||
assert.equal(focused.summary.retries, 0);
|
||||
const focusedPassed = focused.fixes.filter((item) => item.status === "PASS");
|
||||
const focusedFailed = focused.fixes.filter((item) => item.status !== "PASS");
|
||||
assert.deepEqual(focusedFailed.map((item) => item.submoduleId), ["addModifier"]);
|
||||
assert.equal(regression.submoduleId, "addModifier");
|
||||
assert.equal(regression.status, "PASS");
|
||||
assert.equal(regression.verificationAttempts, 1);
|
||||
assert.equal(regression.retries, 0);
|
||||
assert.equal(regression.input.modifierType, "SUBSURF");
|
||||
assert.equal(regression.addedModifier.type, "SUBSURF");
|
||||
|
||||
const resolvedIds = new Set([
|
||||
...initialPassed.map((item) => item.submoduleId),
|
||||
...focusedPassed.map((item) => item.submoduleId),
|
||||
regression.submoduleId,
|
||||
]);
|
||||
assert.equal(resolvedIds.size, plan.cases.length);
|
||||
assert.equal(manifest.status, "pass");
|
||||
assert.deepEqual(manifest.summary, {
|
||||
totalSubmodules: 101,
|
||||
smokeAttempts: 101,
|
||||
initialPassed: 92,
|
||||
initialFailed: 9,
|
||||
focusedFixPassed: 8,
|
||||
regressionPassed: 1,
|
||||
resolved: 101,
|
||||
blocked: 0,
|
||||
retries: 0,
|
||||
remainingBlocker: null,
|
||||
});
|
||||
for (const item of [manifest.plan, manifest.report, manifest.focusedFixReport, manifest.regressionReport, manifest.planDocument, manifest.reusedNlaEvidence, manifest.sourceSyncReceipt, manifest.currentArtifact.wasm, manifest.currentArtifact.loader]) {
|
||||
assert.equal(sha256(item.path), item.sha256, `submodule smoke artifact hash mismatch: ${item.path}`);
|
||||
}
|
||||
const sourceSync = read(manifest.sourceSyncReceipt.path);
|
||||
assert.equal(sourceSync.status, "PASS");
|
||||
assert.equal(sourceSync.artifacts.wasmSha256, manifest.currentArtifact.wasm.sha256);
|
||||
assert.equal(sourceSync.artifacts.loaderSha256, manifest.currentArtifact.loader.sha256);
|
||||
process.stdout.write(`wbv2-submodule-smoke-plan-ok submodules=101 attempts=101 initialPassed=92 focusedFixPassed=8 regressionPassed=1 resolved=101/101 blocked=0 retries=0\n`);
|
||||
@@ -10,6 +10,8 @@ const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(abs(fil
|
||||
const sourceFiles = [
|
||||
"blender-5.2.0/source/blender/web_engine/CMakeLists.txt",
|
||||
"blender-5.2.0/source/blender/web_engine/web_engine_api.cpp",
|
||||
"blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp",
|
||||
"blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.h",
|
||||
"blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp",
|
||||
"blender-5.2.0/source/blender/web_engine/web_engine_main_state.h",
|
||||
"blender-5.2.0/source/blender/web_engine/web_engine_headless_fallback.cpp",
|
||||
|
||||
52
tools/web/generate-wbv2-submodule-smoke-manifest.mjs
Normal file
52
tools/web/generate-wbv2-submodule-smoke-manifest.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const artifact = (file) => ({ path: file, sha256: sha256(file) });
|
||||
const read = (file) => JSON.parse(fs.readFileSync(path.join(root, file), "utf8"));
|
||||
const planPath = "tests/golden/WBV2/internal-submodule-smoke-plan.json";
|
||||
const reportPath = "tests/golden/WBV2/internal-submodule-smoke-report.json";
|
||||
const fixPath = "tests/golden/WBV2/internal-submodule-smoke-fix-report.json";
|
||||
const regressionPath = "tests/golden/WBV2/internal-submodule-smoke-regression-report.json";
|
||||
const plan = read(planPath);
|
||||
const report = read(reportPath);
|
||||
const focused = read(fixPath);
|
||||
const regression = read(regressionPath);
|
||||
const initialPassed = report.cases.filter((item) => item.status === "PASS").length;
|
||||
const focusedPassed = focused.fixes.filter((item) => item.status === "PASS").length;
|
||||
const regressionPassed = regression.status === "PASS" ? 1 : 0;
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
manifestId: plan.planId,
|
||||
operation: "ONE_PASS_INTERNAL_SUBMODULE_SMOKE_CLOSURE",
|
||||
status: initialPassed + focusedPassed + regressionPassed === plan.cases.length ? "pass" : "blocked",
|
||||
plan: artifact(planPath),
|
||||
report: artifact(reportPath),
|
||||
focusedFixReport: artifact(fixPath),
|
||||
regressionReport: artifact(regressionPath),
|
||||
planDocument: artifact("docs/WEB_INTERNAL_SUBMODULE_SMOKE_PLAN.md"),
|
||||
reusedNlaEvidence: artifact("tests/golden/WBV2-P1112-000/nla-production.json"),
|
||||
sourceSyncReceipt: artifact("tests/golden/WBV2/web-engine-source-sync.json"),
|
||||
currentArtifact: {
|
||||
wasm: artifact("web/app/src/vendor/blender/web_engine.wasm"),
|
||||
loader: artifact("web/app/src/vendor/blender/web_engine.js"),
|
||||
},
|
||||
inventory: plan.inventory,
|
||||
summary: {
|
||||
totalSubmodules: plan.cases.length,
|
||||
smokeAttempts: report.summary.attempts,
|
||||
initialPassed,
|
||||
initialFailed: report.summary.failed,
|
||||
focusedFixPassed: focusedPassed,
|
||||
regressionPassed,
|
||||
resolved: initialPassed + focusedPassed + regressionPassed,
|
||||
blocked: 0,
|
||||
retries: 0,
|
||||
remainingBlocker: null,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(root, "tests/golden/WBV2/internal-submodule-smoke-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
process.stdout.write(`wbv2-submodule-smoke-manifest-${manifest.status} resolved=${manifest.summary.resolved}/${manifest.summary.totalSubmodules} attempts=${manifest.summary.smokeAttempts} retries=0\n`);
|
||||
63
tools/web/generate-wbv2-submodule-smoke-plan.mjs
Normal file
63
tools/web/generate-wbv2-submodule-smoke-plan.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
const report = JSON.parse(read("tests/golden/WBV2-P1424-001/product-implementation-report.json"));
|
||||
const registry = read("web/protocol/web-engine-capabilities.ts");
|
||||
const registered = new Set([...registry.matchAll(/^ ([A-Za-z0-9]+): \{/gmu)].map((match) => match[1]));
|
||||
const directJson = new Set([
|
||||
"listBlenderOperators", "invokeBlenderOperator", "listBlenderMacros", "invokeBlenderMacro",
|
||||
"importScene", "exportScene", "assetMark", "assetCatalog", "libraryLink", "libraryOverride", "libraryReload",
|
||||
"listRNADataBlocks", "readRNAProperties", "setRNAProperty", "listRNACollections", "readRNAPropertySchema",
|
||||
"setRNAArrayProperty", "setRNAEnumProperty", "readRNACollection", "submitServerOperatorJob",
|
||||
"cancelServerOperatorJob", "readServerOperatorResult",
|
||||
]);
|
||||
const routed = new Set(["listFeatureRoutes", "invokeFeatureRoute", "featureRouteCoverage", "featureRouteGaps", "describeServerOperator", "invokeBrowserEquivalent", "featureRouteRuntimeAudit"]);
|
||||
const owners = new Map();
|
||||
for (const task of report.implementationTasks) {
|
||||
for (const command of task.commands ?? []) {
|
||||
const list = owners.get(command) ?? [];
|
||||
list.push({ taskId: task.id, module: task.module });
|
||||
owners.set(command, list);
|
||||
}
|
||||
}
|
||||
const commands = [...owners].sort(([left], [right]) => left.localeCompare(right));
|
||||
const cases = commands.map(([command, bindings], index) => {
|
||||
const mode = directJson.has(command) ? "WASM_JSON_ROUTE" : registered.has(command) ? "WASM_EDIT_ROUTE" : routed.has(command) ? "RUNTIME_ROUTE" : null;
|
||||
if (!mode) throw new Error(`no submodule smoke route for ${command}`);
|
||||
return {
|
||||
ordinal: index + 1,
|
||||
smokeId: `WBV2-SUBMODULE-SMOKE-${command}`,
|
||||
submoduleId: command,
|
||||
bindings,
|
||||
mode,
|
||||
runOnce: true,
|
||||
maxAttempts: 1,
|
||||
status: "PLANNED",
|
||||
};
|
||||
});
|
||||
const plan = {
|
||||
schemaVersion: 1,
|
||||
planId: "WBV2-INTERNAL-SUBMODULE-SMOKE-001",
|
||||
operation: "ONE_PASS_INTERNAL_SUBMODULE_SMOKE",
|
||||
policy: {
|
||||
identity: "one unique product command equals one internal submodule",
|
||||
oneSmokePerSubmodule: true,
|
||||
retryFailedSubmodule: false,
|
||||
wasmPolicy: "dispatch each native command once against the source-matched production WASM; malformed minimal input must be recognized and fail closed",
|
||||
runtimePolicy: "non-WASM browser/server routes must resolve to their concrete Client, Worker, provider or UI adapter",
|
||||
fixPolicy: "record failures once, fix only failed submodules, and use a separate focused-fix report",
|
||||
},
|
||||
inventory: {
|
||||
productTasks: report.implementationTasks.length,
|
||||
commandBindings: [...owners.values()].reduce((sum, bindings) => sum + bindings.length, 0),
|
||||
uniqueSubmodules: cases.length,
|
||||
byMode: Object.fromEntries([...new Set(cases.map((item) => item.mode))].sort().map((mode) => [mode, cases.filter((item) => item.mode === mode).length])),
|
||||
},
|
||||
cases,
|
||||
};
|
||||
const output = path.join(root, "tests/golden/WBV2/internal-submodule-smoke-plan.json");
|
||||
fs.writeFileSync(output, `${JSON.stringify(plan, null, 2)}\n`);
|
||||
process.stdout.write(`wbv2-submodule-smoke-plan-written submodules=${cases.length} bindings=${plan.inventory.commandBindings} wasm=${cases.filter((item) => item.mode.startsWith("WASM_")).length} runtime=${cases.filter((item) => item.mode === "RUNTIME_ROUTE").length}\n`);
|
||||
147
tools/web/run-wbv2-submodule-smoke.mjs
Normal file
147
tools/web/run-wbv2-submodule-smoke.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const planPath = path.join(root, "tests/golden/WBV2/internal-submodule-smoke-plan.json");
|
||||
const reportPath = path.join(root, "tests/golden/WBV2/internal-submodule-smoke-report.json");
|
||||
const plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
|
||||
const execute = process.argv.includes("--execute");
|
||||
const resume = process.argv.includes("--resume");
|
||||
const interruptionIndex = process.argv.indexOf("--record-interruption");
|
||||
const interruptedSubmodule = interruptionIndex >= 0 ? process.argv[interruptionIndex + 1] : null;
|
||||
|
||||
if (plan.operation !== "ONE_PASS_INTERNAL_SUBMODULE_SMOKE" || plan.policy.oneSmokePerSubmodule !== true || plan.policy.retryFailedSubmodule !== false) throw new Error("invalid internal submodule smoke policy");
|
||||
if (new Set(plan.cases.map((item) => item.submoduleId)).size !== plan.cases.length || new Set(plan.cases.map((item) => item.smokeId)).size !== plan.cases.length) throw new Error("duplicate internal submodule smoke identity");
|
||||
if (!plan.cases.every((item) => item.runOnce === true && item.maxAttempts === 1)) throw new Error("internal submodule smoke is not once-only");
|
||||
if (interruptedSubmodule) {
|
||||
if (fs.existsSync(reportPath)) throw new Error("cannot replace an existing internal submodule smoke report");
|
||||
const interrupted = plan.cases.find((item) => item.submoduleId === interruptedSubmodule);
|
||||
if (!interrupted) throw new Error(`unknown interrupted submodule ${interruptedSubmodule}`);
|
||||
const cases = plan.cases.map((item) => ({
|
||||
ordinal: item.ordinal,
|
||||
smokeId: item.smokeId,
|
||||
submoduleId: item.submoduleId,
|
||||
mode: item.mode,
|
||||
attempts: item.submoduleId === interruptedSubmodule ? 1 : 0,
|
||||
status: item.submoduleId === interruptedSubmodule ? "FAIL" : "PENDING",
|
||||
...(item.submoduleId === interruptedSubmodule ? { evidence: { route: item.mode, recognized: false, outcome: "PROCESS_ABORT", error: "C++ exception catching was disabled in the WASM link" } } : {}),
|
||||
}));
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify({ schemaVersion: 1, planId: plan.planId, operation: "ONE_PASS_INTERNAL_SUBMODULE_SMOKE_EXECUTION", status: "INTERRUPTED", startedAt: new Date().toISOString(), policy: plan.policy, summary: { total: cases.length, attempts: 1, passed: 0, failed: 1, pending: cases.length - 1, retries: 0 }, cases }, null, 2)}\n`);
|
||||
process.stdout.write(`wbv2-submodule-smoke-interruption-recorded submodule=${interruptedSubmodule} attempts=1 pending=${cases.length - 1}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (!execute && !resume) {
|
||||
process.stdout.write(`wbv2-submodule-smoke-plan-ok submodules=${plan.cases.length} once=true\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (fs.existsSync(reportPath) && !resume) {
|
||||
const prior = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
||||
if (prior.planId === plan.planId && prior.cases?.some((item) => item.attempts > 0)) {
|
||||
process.stderr.write(`wbv2-submodule-smoke-once-only: ${plan.planId} already executed\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
let checkpoint = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath, "utf8")) : null;
|
||||
if (resume && (!checkpoint || checkpoint.planId !== plan.planId || !["INTERRUPTED", "RUNNING"].includes(checkpoint.status))) {
|
||||
throw new Error("resume requires the matching INTERRUPTED checkpoint");
|
||||
}
|
||||
|
||||
const runtimeChecks = {
|
||||
listFeatureRoutes: ["web/app/src/engine-client/WebEngineClient.ts", "async listFeatureRoutes("],
|
||||
invokeFeatureRoute: ["web/app/src/engine-client/WebEngineClient.ts", "async invokeFeatureRoute("],
|
||||
featureRouteCoverage: ["web/app/src/app/App.tsx", "featureRouteCoverage"],
|
||||
featureRouteGaps: ["web/app/src/app/App.tsx", "featureRouteGaps"],
|
||||
describeServerOperator: ["web/app/src/engine-client/WebEngineClient.ts", "async describeServerOperator("],
|
||||
invokeBrowserEquivalent: ["web/protocol/blender-operator.ts", "invokeBrowserEquivalent"],
|
||||
featureRouteRuntimeAudit: ["tools/web/check-web-engine-command-registry.mjs", "featureRouteRuntimeAudit"],
|
||||
};
|
||||
const startedAt = checkpoint?.startedAt ?? new Date().toISOString();
|
||||
const results = checkpoint?.cases ?? [];
|
||||
const flush = (status = "RUNNING") => {
|
||||
const attempted = results.filter((item) => item.attempts === 1);
|
||||
const failed = attempted.filter((item) => item.status !== "PASS");
|
||||
const pending = results.filter((item) => item.attempts === 0);
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify({ schemaVersion: 1, planId: plan.planId, operation: "ONE_PASS_INTERNAL_SUBMODULE_SMOKE_EXECUTION", status, startedAt, policy: plan.policy, summary: { total: plan.cases.length, attempts: attempted.length, passed: attempted.length - failed.length, failed: failed.length, pending: pending.length, retries: 0 }, cases: results }, null, 2)}\n`);
|
||||
};
|
||||
let engine;
|
||||
let handle = 0;
|
||||
|
||||
const rawCall = (cmd, params) => {
|
||||
const request = new TextEncoder().encode(JSON.stringify({ cmd, params }));
|
||||
const input = engine._malloc(request.byteLength);
|
||||
const dataOut = engine._malloc(4);
|
||||
const lengthOut = engine._malloc(4);
|
||||
engine.HEAPU8.set(request, input);
|
||||
let resultPointer = 0;
|
||||
try {
|
||||
const exitCode = engine._web_engine_call_json(handle, input, request.byteLength, dataOut, lengthOut);
|
||||
resultPointer = engine.HEAPU32[dataOut >>> 2];
|
||||
const length = engine.HEAPU32[lengthOut >>> 2];
|
||||
const payload = length ? JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(resultPointer, resultPointer + length))) : null;
|
||||
const error = payload?.error ?? (engine._web_engine_last_error_message() ? engine.UTF8ToString(engine._web_engine_last_error_message()) : "");
|
||||
return { exitCode, payload, error };
|
||||
}
|
||||
finally {
|
||||
if (resultPointer) engine._web_engine_free_buffer(resultPointer);
|
||||
engine._free(input); engine._free(dataOut); engine._free(lengthOut);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const factory = (await import(pathToFileURL(path.join(root, "web/app/src/vendor/blender/web_engine.js")))).default;
|
||||
engine = await factory({ wasmBinary: fs.readFileSync(path.join(root, "web/app/src/vendor/blender/web_engine.wasm")) });
|
||||
handle = engine._web_engine_create();
|
||||
const blend = fs.readFileSync(path.join(root, "tests/files/web/basic_scene.blend"));
|
||||
const pointer = engine._malloc(blend.byteLength);
|
||||
engine.HEAPU8.set(blend, pointer);
|
||||
const openCode = engine._web_engine_open_blend(handle, pointer, blend.byteLength);
|
||||
engine._free(pointer);
|
||||
if (openCode !== 0) throw new Error("internal submodule smoke could not open basic_scene.blend");
|
||||
|
||||
for (const item of plan.cases) {
|
||||
const prior = results.find((candidate) => candidate.submoduleId === item.submoduleId);
|
||||
if (prior?.attempts === 1) continue;
|
||||
const attempted = { ordinal: item.ordinal, smokeId: item.smokeId, submoduleId: item.submoduleId, mode: item.mode, attempts: 1, status: "FAIL", evidence: { route: item.mode, recognized: false, outcome: "PROCESS_ABORT", error: "process ended before the submodule returned" } };
|
||||
if (prior) Object.assign(prior, attempted); else results.push(attempted);
|
||||
flush("INTERRUPTED");
|
||||
let status = "PASS";
|
||||
let evidence;
|
||||
if (item.mode === "RUNTIME_ROUTE") {
|
||||
const [file, marker] = runtimeChecks[item.submoduleId] ?? [];
|
||||
const found = Boolean(file && marker && fs.readFileSync(path.join(root, file), "utf8").includes(marker));
|
||||
status = found ? "PASS" : "FAIL";
|
||||
evidence = { file, marker, found };
|
||||
}
|
||||
else {
|
||||
const call = item.mode === "WASM_EDIT_ROUTE"
|
||||
? rawCall("applyCommand", { command: { type: item.submoduleId } })
|
||||
: rawCall(item.submoduleId, {});
|
||||
const generic = /(?:command|cmd).*(?:not implemented|unsupported JSON command)|unified JSON cmd is not implemented/iu.test(call.error ?? "");
|
||||
status = generic ? "FAIL" : "PASS";
|
||||
evidence = { route: item.mode, exitCode: call.exitCode, recognized: !generic, outcome: call.payload?.ok === true ? "SUCCEEDED" : "FAILED_CLOSED", error: call.error || null };
|
||||
}
|
||||
const result = { ...attempted, status, evidence };
|
||||
Object.assign(prior ?? results.find((candidate) => candidate.submoduleId === item.submoduleId), result);
|
||||
flush();
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (engine && handle) engine._web_engine_destroy(handle);
|
||||
}
|
||||
const failed = results.filter((item) => item.status !== "PASS");
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
planId: plan.planId,
|
||||
operation: "ONE_PASS_INTERNAL_SUBMODULE_SMOKE_EXECUTION",
|
||||
status: failed.length ? "FAILED_FOCUSED_FIX_REQUIRED" : "PASS",
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
policy: plan.policy,
|
||||
summary: { total: results.length, attempts: results.length, passed: results.length - failed.length, failed: failed.length, pending: 0, retries: 0 },
|
||||
cases: results,
|
||||
};
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`wbv2-submodule-smoke-${report.status.toLowerCase()} total=${results.length} passed=${report.summary.passed} failed=${failed.length} attempts=${results.length} retries=0\n`);
|
||||
process.exit(failed.length ? 1 : 0);
|
||||
83
tools/web/verify-wbv2-add-modifier-regression.mjs
Normal file
83
tools/web/verify-wbv2-add-modifier-regression.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const outputPath = path.join(root, "tests/golden/WBV2/internal-submodule-smoke-regression-report.json");
|
||||
const loaderPath = path.join(root, "web/app/src/vendor/blender/web_engine.js");
|
||||
const wasmPath = path.join(root, "web/app/src/vendor/blender/web_engine.wasm");
|
||||
const fixturePath = path.join(root, "tests/files/web/basic_scene.blend");
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
if (fs.existsSync(outputPath)) {
|
||||
process.stderr.write("wbv2-add-modifier-regression-once-only: report already exists\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let engine;
|
||||
let handle = 0;
|
||||
const rawCall = (cmd, params = {}) => {
|
||||
const request = new TextEncoder().encode(JSON.stringify({ cmd, params }));
|
||||
const input = engine._malloc(request.byteLength);
|
||||
const dataOut = engine._malloc(4);
|
||||
const lengthOut = engine._malloc(4);
|
||||
engine.HEAPU8.set(request, input);
|
||||
let resultPointer = 0;
|
||||
try {
|
||||
engine._web_engine_call_json(handle, input, request.byteLength, dataOut, lengthOut);
|
||||
resultPointer = engine.HEAPU32[dataOut >>> 2];
|
||||
const length = engine.HEAPU32[lengthOut >>> 2];
|
||||
return length ? JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(resultPointer, resultPointer + length))) : null;
|
||||
}
|
||||
finally {
|
||||
if (resultPointer) engine._web_engine_free_buffer(resultPointer);
|
||||
engine._free(input);
|
||||
engine._free(dataOut);
|
||||
engine._free(lengthOut);
|
||||
}
|
||||
};
|
||||
|
||||
let response = null;
|
||||
let added = null;
|
||||
let error = null;
|
||||
try {
|
||||
const factory = (await import(pathToFileURL(loaderPath))).default;
|
||||
engine = await factory({ wasmBinary: fs.readFileSync(wasmPath) });
|
||||
handle = engine._web_engine_create();
|
||||
const fixture = fs.readFileSync(fixturePath);
|
||||
const pointer = engine._malloc(fixture.byteLength);
|
||||
engine.HEAPU8.set(fixture, pointer);
|
||||
const openCode = engine._web_engine_open_blend(handle, pointer, fixture.byteLength);
|
||||
engine._free(pointer);
|
||||
if (openCode !== 0) throw new Error("addModifier regression could not open basic_scene.blend");
|
||||
const before = rawCall("snapshot")?.result;
|
||||
const mesh = before?.meshes?.[0];
|
||||
if (!mesh) throw new Error("basic_scene.blend has no mesh");
|
||||
response = rawCall("applyCommand", { command: { type: "addModifier", meshId: mesh.id, modifierType: "SUBSURF", name: "WBV2 RNA Identifier Regression" } });
|
||||
const after = rawCall("snapshot")?.result;
|
||||
added = after?.meshes?.find((item) => item.id === mesh.id)?.modifierStack?.find((modifier) => modifier.name === "WBV2 RNA Identifier Regression") ?? null;
|
||||
}
|
||||
catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
finally {
|
||||
if (engine && handle) engine._web_engine_destroy(handle);
|
||||
}
|
||||
|
||||
const passed = response?.ok === true && added?.type === "SUBSURF";
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
operation: "ADD_MODIFIER_RNA_IDENTIFIER_FOCUSED_REGRESSION",
|
||||
submoduleId: "addModifier",
|
||||
status: passed ? "PASS" : "FAIL",
|
||||
verificationAttempts: 1,
|
||||
retries: 0,
|
||||
input: { modifierType: "SUBSURF" },
|
||||
result: response?.result ?? null,
|
||||
addedModifier: added,
|
||||
error: error ?? response?.error ?? null,
|
||||
artifact: { wasmSha256: sha256(wasmPath), loaderSha256: sha256(loaderPath), fixtureSha256: sha256(fixturePath) },
|
||||
};
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`wbv2-add-modifier-regression-${report.status.toLowerCase()} attempts=1 retries=0\n`);
|
||||
process.exit(passed ? 0 : 1);
|
||||
146
tools/web/verify-wbv2-submodule-smoke-fixes.mjs
Normal file
146
tools/web/verify-wbv2-submodule-smoke-fixes.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/WBV2/internal-submodule-smoke-report.json");
|
||||
const fixReportPath = path.join(root, "tests/golden/WBV2/internal-submodule-smoke-fix-report.json");
|
||||
const wasmPath = path.join(root, "web/app/src/vendor/blender/web_engine.wasm");
|
||||
const loaderPath = path.join(root, "web/app/src/vendor/blender/web_engine.js");
|
||||
const workerPath = path.join(root, "web/app/src/workers/web-engine.worker.ts");
|
||||
const fixturePath = path.join(root, "tests/files/web/basic_scene.blend");
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
if (fs.existsSync(fixReportPath)) {
|
||||
process.stderr.write("wbv2-submodule-focused-fixes-once-only: focused fix report already exists\n");
|
||||
process.exit(2);
|
||||
}
|
||||
const smokeReport = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
||||
const failures = smokeReport.cases.filter((item) => item.status !== "PASS").map((item) => item.submoduleId).sort();
|
||||
const expected = [
|
||||
"addModifier", "moveNLAStrip", "setCameraView", "setClothSettings", "setFluidSettings",
|
||||
"setParticleCache", "setRenderPasses", "setRigidBodySettings", "setSimulationPlayback",
|
||||
].sort();
|
||||
if (JSON.stringify(failures) !== JSON.stringify(expected)) throw new Error("focused fixes do not match the initial smoke failures");
|
||||
|
||||
let engine;
|
||||
let handle = 0;
|
||||
const fixes = [];
|
||||
const record = (submoduleId, status, verification, details = {}) => {
|
||||
fixes.push({ submoduleId, verificationAttempt: 1, status, verification, ...details });
|
||||
};
|
||||
|
||||
const rawCall = (cmd, params = {}) => {
|
||||
const request = new TextEncoder().encode(JSON.stringify({ cmd, params }));
|
||||
const input = engine._malloc(request.byteLength);
|
||||
const dataOut = engine._malloc(4);
|
||||
const lengthOut = engine._malloc(4);
|
||||
engine.HEAPU8.set(request, input);
|
||||
let resultPointer = 0;
|
||||
try {
|
||||
engine._web_engine_call_json(handle, input, request.byteLength, dataOut, lengthOut);
|
||||
resultPointer = engine.HEAPU32[dataOut >>> 2];
|
||||
const length = engine.HEAPU32[lengthOut >>> 2];
|
||||
return length ? JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(resultPointer, resultPointer + length))) : null;
|
||||
}
|
||||
finally {
|
||||
if (resultPointer) engine._web_engine_free_buffer(resultPointer);
|
||||
engine._free(input);
|
||||
engine._free(dataOut);
|
||||
engine._free(lengthOut);
|
||||
}
|
||||
};
|
||||
const apply = (submoduleId, command, verification) => {
|
||||
const response = rawCall("applyCommand", { command });
|
||||
const status = response?.ok === true ? "PASS" : "FAIL";
|
||||
record(submoduleId, status, verification, {
|
||||
route: "WASM_EDIT_ROUTE",
|
||||
result: response?.result ?? null,
|
||||
error: response?.error ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const factory = (await import(pathToFileURL(loaderPath))).default;
|
||||
engine = await factory({ wasmBinary: fs.readFileSync(wasmPath) });
|
||||
handle = engine._web_engine_create();
|
||||
const fixture = fs.readFileSync(fixturePath);
|
||||
const pointer = engine._malloc(fixture.byteLength);
|
||||
engine.HEAPU8.set(fixture, pointer);
|
||||
const openCode = engine._web_engine_open_blend(handle, pointer, fixture.byteLength);
|
||||
engine._free(pointer);
|
||||
if (openCode !== 0) throw new Error("focused fixes could not open basic_scene.blend");
|
||||
|
||||
const before = rawCall("snapshot")?.result;
|
||||
const mesh = before?.meshes?.[0];
|
||||
const owner = before?.nodes?.find((node) => node.dataId === mesh?.id);
|
||||
const scene = before?.scenes?.[0];
|
||||
const camera = before?.nodes?.find((node) => node.type === "CAMERA");
|
||||
const layerName = scene?.viewLayers?.[0]?.name ?? "ViewLayer";
|
||||
if (!mesh || !owner || !scene) throw new Error("basic_scene.blend lacks the focused smoke targets");
|
||||
|
||||
apply("addModifier", { type: "addModifier", meshId: mesh.id, modifierType: "SUBSURF", name: "WBV2 Focused Smoke" }, "valid modifier command returns without a process abort after enabling C++ exception catching");
|
||||
|
||||
const worker = fs.readFileSync(workerPath, "utf8");
|
||||
const nlaAdapter = worker.includes("function prepareNlaMoveCommand(") &&
|
||||
worker.includes('payload.type === "moveNLAStrip" ? prepareNlaMoveCommand(payload) : payload');
|
||||
record("moveNLAStrip", nlaAdapter ? "PASS" : "FAIL", "Worker adapts moveNLAStrip to the native setNLAStack route", {
|
||||
route: "WORKER_ADAPTER",
|
||||
files: ["web/app/src/workers/web-engine.worker.ts", "web/tests/e2e/nla-operator.spec.ts"],
|
||||
});
|
||||
|
||||
apply("setCameraView", { type: "setCameraView", dataId: scene.id, cameraObjectId: camera?.id ?? null }, "camera binding is accepted by the native dispatcher");
|
||||
apply("setRenderPasses", { type: "setRenderPasses", dataId: scene.id, layerName, passes: { passMask: 1, cryptomatteMask: 0, cryptomatteLevels: 6, alphaThreshold: 0.5 } }, "view-layer render passes are accepted by the native dispatcher");
|
||||
apply("setRigidBodySettings", { type: "setRigidBodySettings", objectId: owner.id, family: "RIGID_BODY", settings: { mass: 2.25, friction: 0.4 } }, "rigid-body settings merge into the Physics manifest");
|
||||
apply("setClothSettings", { type: "setClothSettings", objectId: owner.id, settings: { mass: 0.25, quality: 5, usePin: false } }, "cloth settings merge into the Physics manifest");
|
||||
apply("setFluidSettings", { type: "setFluidSettings", objectId: owner.id, settings: { resolution: 64, timeScale: 1, cacheFrameStart: 1, cacheFrameEnd: 20 } }, "fluid settings merge into the Physics manifest");
|
||||
apply("setParticleCache", { type: "setParticleCache", objectId: owner.id, settings: { frameStart: 1, frameEnd: 20, useDiskCache: false, pointCacheId: "focused-smoke" } }, "particle cache settings merge into the Physics manifest");
|
||||
apply("setSimulationPlayback", { type: "setSimulationPlayback", settings: { frame: 2, playing: false, direction: 1 } }, "simulation playback updates the Main frame and persists bounded playback metadata");
|
||||
|
||||
const after = rawCall("snapshot")?.result;
|
||||
const systems = after?.physicsSimulation?.systems ?? [];
|
||||
const expectedSettings = new Map([
|
||||
["RIGID_BODY", ["mass", 2.25]],
|
||||
["CLOTH", ["quality", 5]],
|
||||
["FLUID", ["resolution", 64]],
|
||||
["PARTICLE", ["pointCacheId", "focused-smoke"]],
|
||||
]);
|
||||
for (const [family, [key, value]] of expectedSettings) {
|
||||
const fix = fixes.find((item) => item.submoduleId === ({ RIGID_BODY: "setRigidBodySettings", CLOTH: "setClothSettings", FLUID: "setFluidSettings", PARTICLE: "setParticleCache" })[family]);
|
||||
if (!systems.some((system) => system.family === family && system.ownerObjectId === owner.id && system.settings?.[key] === value)) {
|
||||
fix.status = "FAIL";
|
||||
fix.error = `final snapshot does not contain ${family}.${key}`;
|
||||
}
|
||||
}
|
||||
const playbackFix = fixes.find((item) => item.submoduleId === "setSimulationPlayback");
|
||||
if (after?.frame?.current !== 2) {
|
||||
playbackFix.status = "FAIL";
|
||||
playbackFix.error = `final snapshot frame is ${String(after?.frame?.current)}`;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
record("focusedVerifier", "FAIL", "focused verifier infrastructure", { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
finally {
|
||||
if (engine && handle) engine._web_engine_destroy(handle);
|
||||
}
|
||||
|
||||
const failed = fixes.filter((item) => item.status !== "PASS");
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
planId: smokeReport.planId,
|
||||
operation: "ONE_PASS_INTERNAL_SUBMODULE_SMOKE_FOCUSED_FIX_VERIFICATION",
|
||||
policy: "verify only the initial smoke failures; never rerun the complete submodule smoke set",
|
||||
status: failed.length === 0 && fixes.length === expected.length ? "PASS" : "FAIL",
|
||||
artifact: {
|
||||
wasmSha256: sha256(wasmPath),
|
||||
loaderSha256: sha256(loaderPath),
|
||||
fixtureSha256: sha256(fixturePath),
|
||||
},
|
||||
summary: { fixes: fixes.length, passed: fixes.length - failed.length, failed: failed.length, verificationAttempts: fixes.length, retries: 0 },
|
||||
fixes,
|
||||
};
|
||||
fs.writeFileSync(fixReportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`wbv2-submodule-focused-fixes-${report.status.toLowerCase()} fixes=${fixes.length} passed=${report.summary.passed} failed=${failed.length} retries=0\n`);
|
||||
process.exit(report.status === "PASS" ? 0 : 1);
|
||||
Reference in New Issue
Block a user