test(web): add one-pass feature smoke plan and record blockers
This commit is contained in:
26
tools/web/check-wbv2-feature-smoke-plan.mjs
Normal file
26
tools/web/check-wbv2-feature-smoke-plan.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
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 readJson = (file) => JSON.parse(fs.readFileSync(abs(file), "utf8"));
|
||||
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(abs(file))).digest("hex");
|
||||
const manifest = readJson("tests/golden/WBV2/feature-smoke-manifest.json");
|
||||
const plan = readJson(manifest.plan.path);
|
||||
const report = readJson(manifest.report.path);
|
||||
const focused = readJson(manifest.focusedFixReport.path);
|
||||
|
||||
assert.equal(plan.operation, "ONE_PASS_FEATURE_SMOKE");
|
||||
assert.equal(plan.policy.oneSmokePerFeature, true);
|
||||
assert.equal(plan.policy.retryFailedFeature, false);
|
||||
assert.equal(plan.cases.length, manifest.summary.totalFeatures);
|
||||
assert.equal(new Set(plan.cases.map((item) => item.featureId)).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.equal(focused.retries, 0);
|
||||
for (const artifact of [manifest.plan, manifest.report, manifest.focusedFixReport, manifest.planDocument]) assert.equal(sha(artifact.path), artifact.sha256, `smoke artifact hash mismatch ${artifact.path}`);
|
||||
process.stdout.write(`wbv2-feature-smoke-plan-ok features=${plan.cases.length} attempts=${manifest.summary.smokeAttempts} initialPassed=${manifest.summary.initialPassed} focusedFixPassed=${manifest.summary.focusedFixPassed} blocked=${manifest.summary.blocked} retries=${focused.retries}\n`);
|
||||
104
tools/web/generate-wbv2-feature-smoke-plan.mjs
Normal file
104
tools/web/generate-wbv2-feature-smoke-plan.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
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 readJson = (relative) => JSON.parse(fs.readFileSync(path.join(root, relative), "utf8"));
|
||||
const writeJson = (relative, value) => {
|
||||
const target = path.join(root, relative);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`);
|
||||
};
|
||||
|
||||
const report = readJson("tests/golden/WBV2-P1424-001/product-implementation-report.json");
|
||||
const catalog = readJson("web/app/src/capabilities/blender-feature-route-catalog.json");
|
||||
const wasmManifest = readJson("web/app/public/wasm-module-manifest.json");
|
||||
|
||||
// One representative production check per implementation task. The selector is
|
||||
// deliberately explicit: the runner groups selectors by file, but never retries
|
||||
// a case or runs a second selector for the same feature.
|
||||
const selectors = {
|
||||
"WEBUI-NONMESH-ADVANCED": ["playwright", "tests/e2e/wbv2-nonmesh-delete-production.spec.ts", "WBV2-P1101-000 preserves deleteNonMeshData Main recovery semantics"],
|
||||
"WEBUI-FONT-ASSETS": ["playwright", "tests/e2e/wbv2-font-advanced-production.spec.ts", "WBV2-P1136-001 preserves Font advanced fields and links through Main recovery"],
|
||||
"WEBUI-SCULPT": ["playwright", "tests/e2e/wbv2-sculpt-stroke-production.spec.ts", "WBV2-P1121-000 preserves bounded Sculpt stroke Main mutation"],
|
||||
"WEBUI-NLA-STRIP": ["playwright", "tests/e2e/wbv2-nla-stack-production.spec.ts", "WBV2-P1112-000 preserves setNLAStack Main submission and rejects invalid actions"],
|
||||
"NATIVE-OPERATOR-BRIDGE": ["playwright", "tests/e2e/wbv2-native-engine-production.spec.ts", "WBV2-P10-002 reaches the compiled Blender WASM Main through Chromium"],
|
||||
"WEBUI-OPERATOR-PALETTE": ["playwright", "tests/e2e/smoke.spec.ts", "operator search executes context-filtered workspace, mode and Main commands"],
|
||||
"MESH-EXTENDED-AUTHORING": ["playwright", "tests/e2e/wbv2-primitive-cube-batch-production.spec.ts", "native primitive cube batch preserves Main mutation and recovery"],
|
||||
"MODIFIER-STACK-AUTHORING": ["playwright", "tests/e2e/wbv2-modifier-production.spec.ts", "WBV2-P9-002 production Chromium Modifier receipt is exact and fail-closed"],
|
||||
"NODE-GRAPH-AUTHORING": ["playwright", "tests/e2e/wbv2-geometry-nodes-production.spec.ts", "WBV2-P13-002 binds Geometry Nodes graph evidence to compiled WASM Main"],
|
||||
"RIG-POSE-AUTHORING": ["playwright", "tests/e2e/wbv2-rig-pose-production.spec.ts", "WBV2-P13-003 binds rig pose and skin evidence to compiled WASM Main"],
|
||||
"ACTION-MARKER-AUTHORING": ["playwright", "tests/e2e/wbv2-action-pose-markers-production.spec.ts", "WBV2 Action pose markers are read from Blender Main and survive recovery/reopen"],
|
||||
"SEQUENCER-AUTHORING": ["playwright", "tests/e2e/sequencer-codec-probe.spec.ts", "M11-09 probes IMAGE, SOUND and MOVIE bytes at runtime without extension guessing"],
|
||||
"IO-ROUTE-COMPLETION": ["playwright", "tests/e2e/io-format-operator-production.spec.ts", "WBV2-P1109 binds Blender 5.2 IO operators to Chromium format behavior"],
|
||||
"SCULPT-ADVANCED-AUTHORING": ["playwright", "tests/e2e/wbv2-paint-attributes-production.spec.ts", "WBV2-P16-002 binds paint attributes to compiled WASM Main"],
|
||||
"OBJECT-EDITOR-COVERAGE": ["playwright", "tests/e2e/wbv2-object-transform-production.spec.ts", "WBV2-P23-002 binds applyObjectTransform to compiled WASM Main"],
|
||||
"MESH-EDITOR-COVERAGE": ["playwright", "tests/e2e/wbv2-mesh-production.spec.ts", "WBV2-P9-001 production Chromium Mesh receipt is exact and fail-closed"],
|
||||
"CURVE-SURFACE-COVERAGE": ["playwright", "tests/e2e/wbv2-surface-topology-production.spec.ts", "WBV2-P1134-001 preserves Surface topology transactions through Main recovery and reopen"],
|
||||
"ARMATURE-EDITOR-COVERAGE": ["playwright", "tests/e2e/nla-operator.spec.ts", "M10-12 moves one NLA strip through Main, undo, redo and save/reopen"],
|
||||
"ANIMATION-FCURVE-DRIVER-COVERAGE": ["playwright", "tests/e2e/wbv2-driver-reader-production.spec.ts", "WBV2 driver data is read from Blender Main and survives recovery/reopen"],
|
||||
"SEQUENCER-COMPOSITOR-COVERAGE": ["playwright", "tests/e2e/compositor-main-reader-production.spec.ts", "WBV2-P1104-000 reads compositor NodeTree and links from Blender Main without JS execution"],
|
||||
"PHYSICS-SIMULATION-COVERAGE": ["playwright", "tests/e2e/wbv2-physics-manifest-production.spec.ts", "WBV2-P1117-000 preserves Physics manifest Main persistence and validation"],
|
||||
"TRACKING-MASK-COVERAGE": ["playwright", "tests/e2e/tracking-mask-production.spec.ts", "WBV2-P1127 reads Blender Mask data and selection context in Chromium"],
|
||||
"GREASE-PENCIL-COVERAGE": ["playwright", "tests/e2e/wbv2-grease-pencil-strokes-production.spec.ts", "WBV2-P1114-000 preserves Grease Pencil stroke edits through recovery and reopen"],
|
||||
"RENDER-CAMERA-LIGHT-COVERAGE": ["playwright", "tests/e2e/wbv2-lighting-fields-production.spec.ts", "WBV2-P15-002 binds camera, light, and world fields to compiled WASM Main"],
|
||||
"ASSET-LIBRARY-COVERAGE": ["playwright", "tests/e2e/wbv2-asset-link-override-production.spec.ts", "WBV2-P11-003 enforces linked ownership and local append through compiled WASM Main"],
|
||||
"RNA-DATABLOCK-REFLECTION": ["playwright", "tests/e2e/main-datablocks-reader-production.spec.ts", "WBV2 source reader exposes Blender Main datablock collections without JS simulation"],
|
||||
"SERVER-OPERATOR-JOB": ["command", "node --test web/tests/unit/server-job-result-binding.test.mjs"],
|
||||
"OPERATOR-ROUTE-PALETTE": ["playwright", "tests/e2e/io-format-ui-gate.spec.ts", "M12-05C exposes only matrix-declared file and operator routes"],
|
||||
"OPERATOR-MACRO-COMPOSER": ["command", "node tools/web/check-web-engine-command-registry.mjs"],
|
||||
"RNA-REFLECTION-EXTENDED": ["playwright", "tests/e2e/wbv2-scene-settings-expanded-production.spec.ts", "WBV2-P1129-001 covers the remaining Scene settings Main commands"],
|
||||
"FEATURE-COVERAGE-DASHBOARD": ["command", "python3 my-tools/show_blender_web_scheduler.py"],
|
||||
"SERVER-OPERATOR-HTTP-PROVIDER": ["command", "node --check tools/web/server-operator-job-server.mjs && python3 -m py_compile tools/web/server-operator-job.py"],
|
||||
"SERVER-OPERATOR-SCHEMA-PALETTE": ["playwright", "tests/e2e/wbv2-texture-properties-production.spec.ts", "WBV2-P1139-001 preserves Texture properties through Main recovery and reopen"],
|
||||
"BROWSER-EQUIVALENT-RUNTIME": ["command", "node tools/web/check-blender-feature-route-catalog.mjs"],
|
||||
};
|
||||
|
||||
if (report.status !== "implemented" || report.implementationTasks.length !== 34) {
|
||||
throw new Error("product implementation report is not the closed 34-task report");
|
||||
}
|
||||
const cases = report.implementationTasks.map((task, index) => {
|
||||
const selector = selectors[task.id];
|
||||
if (!selector) throw new Error(`missing one-pass smoke selector for ${task.id}`);
|
||||
const [kind, fileOrCommand, title] = selector;
|
||||
const route = task.commands?.some((command) => /ServerOperator|serverOperator|renderFrame|renderAnimation/u.test(command))
|
||||
? "SERVER_NATIVE"
|
||||
: task.id === "BROWSER-EQUIVALENT-RUNTIME" ? "BROWSER_EQUIVALENT" : "WEB_MAIN";
|
||||
return {
|
||||
ordinal: index + 1,
|
||||
smokeId: `WBV2-SMOKE-${task.id}`,
|
||||
featureId: task.id,
|
||||
module: task.module,
|
||||
representativeCommand: task.commands?.[0] ?? null,
|
||||
route,
|
||||
kind,
|
||||
...(kind === "playwright" ? { testFile: fileOrCommand, testTitle: title } : { command: fileOrCommand }),
|
||||
runOnce: true,
|
||||
maxAttempts: 1,
|
||||
status: "PLANNED",
|
||||
};
|
||||
});
|
||||
const smokePlan = {
|
||||
schemaVersion: 1,
|
||||
planId: "WBV2-FEATURE-SMOKE-001",
|
||||
operation: "ONE_PASS_FEATURE_SMOKE",
|
||||
policy: {
|
||||
browser: "chromium-only",
|
||||
oneSmokePerFeature: true,
|
||||
retryFailedFeature: false,
|
||||
failureAction: "stop-after-report-and-create-focused-fix-task",
|
||||
scope: "one representative positive/reachability smoke per implemented product feature; negative gates remain assertions of the selected production test",
|
||||
excluded: ["full API matrix", "performance soak", "security/fuzz matrix", "cross-browser", "repeat of already passed feature"],
|
||||
},
|
||||
inventory: {
|
||||
productFeatures: cases.length,
|
||||
workflowModules: report.workflowModules.length,
|
||||
sourceUnits: catalog.unitCount,
|
||||
routeCounts: catalog.counts,
|
||||
behavioralEvidence: catalog.behavioralEvidence,
|
||||
wasmModules: wasmManifest.modules.map(({ id, lifecycle, preload }) => ({ id, lifecycle, preload })),
|
||||
},
|
||||
cases,
|
||||
};
|
||||
writeJson("tests/golden/WBV2/feature-smoke-plan.json", smokePlan);
|
||||
process.stdout.write(`wbv2-feature-smoke-plan-written cases=${cases.length} modules=${report.workflowModules.length} sourceUnits=${catalog.unitCount}\n`);
|
||||
113
tools/web/run-wbv2-feature-smoke-plan.mjs
Normal file
113
tools/web/run-wbv2-feature-smoke-plan.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const planPath = path.join(root, "tests/golden/WBV2/feature-smoke-plan.json");
|
||||
const reportPath = path.join(root, "tests/golden/WBV2/feature-smoke-report.json");
|
||||
const plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
|
||||
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/WBV2-P1424-001/product-implementation-report.json"), "utf8"));
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const execute = args.has("--execute");
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`feature-smoke-plan-invalid: ${message}`);
|
||||
}
|
||||
|
||||
if (plan.operation !== "ONE_PASS_FEATURE_SMOKE" || plan.policy.oneSmokePerFeature !== true) fail("missing one-pass policy");
|
||||
if (plan.cases.length !== report.implementationTasks.length) fail(`case count ${plan.cases.length} != task count ${report.implementationTasks.length}`);
|
||||
const ids = new Set();
|
||||
for (const smoke of plan.cases) {
|
||||
if (ids.has(smoke.featureId) || ids.has(smoke.smokeId)) fail(`duplicate feature or smoke id ${smoke.featureId}`);
|
||||
ids.add(smoke.featureId); ids.add(smoke.smokeId);
|
||||
if (smoke.runOnce !== true || smoke.maxAttempts !== 1) fail(`non-idempotent case ${smoke.featureId}`);
|
||||
if (smoke.kind === "playwright") {
|
||||
const file = path.join(root, "web", smoke.testFile);
|
||||
if (!fs.existsSync(file)) fail(`${smoke.featureId} test file missing: ${smoke.testFile}`);
|
||||
if (!fs.readFileSync(file, "utf8").includes(smoke.testTitle)) fail(`${smoke.featureId} test title missing: ${smoke.testTitle}`);
|
||||
}
|
||||
if (smoke.kind === "command" && !smoke.command) fail(`${smoke.featureId} command missing`);
|
||||
}
|
||||
const expected = new Set(report.implementationTasks.map((task) => task.id));
|
||||
for (const id of expected) if (!ids.has(id)) fail(`implementation task has no smoke case: ${id}`);
|
||||
|
||||
const runCheck = (command, cwd = root) => {
|
||||
const result = spawnSync("bash", ["-lc", command], { cwd, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
|
||||
return { command, cwd: path.relative(root, cwd) || ".", exitCode: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
||||
};
|
||||
|
||||
if (!execute) {
|
||||
process.stdout.write(`feature-smoke-plan-ok cases=${plan.cases.length} onePass=true playwright=${plan.cases.filter((item) => item.kind === "playwright").length} command=${plan.cases.filter((item) => item.kind === "command").length}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
const smokeReport = {
|
||||
schemaVersion: 1,
|
||||
planId: plan.planId,
|
||||
operation: "ONE_PASS_FEATURE_SMOKE_EXECUTION",
|
||||
browser: "chromium",
|
||||
startedAt,
|
||||
policy: plan.policy,
|
||||
status: "RUNNING",
|
||||
cases: plan.cases.map((item) => ({ featureId: item.featureId, smokeId: item.smokeId, ordinal: item.ordinal, status: "PENDING", attempts: 0 })),
|
||||
checks: [],
|
||||
};
|
||||
const flush = () => fs.writeFileSync(reportPath, `${JSON.stringify(smokeReport, null, 2)}\n`);
|
||||
flush();
|
||||
|
||||
// These are global preconditions and run once; they are not counted as feature cases.
|
||||
for (const command of [
|
||||
"node tools/web/check-blender-feature-route-catalog.mjs",
|
||||
"node tools/web/check-web-engine-command-registry.mjs",
|
||||
]) {
|
||||
const result = runCheck(command);
|
||||
smokeReport.checks.push(result);
|
||||
if (result.exitCode !== 0) {
|
||||
smokeReport.status = "BLOCKED";
|
||||
smokeReport.finishedAt = new Date().toISOString();
|
||||
flush();
|
||||
process.stderr.write(result.stderr || result.stdout);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Group only cases that share one Playwright file. Each title is still selected
|
||||
// exactly once, and a failed group is never retried in this execution.
|
||||
const playwrightGroups = new Map();
|
||||
for (const item of plan.cases.filter((candidate) => candidate.kind === "playwright")) {
|
||||
const list = playwrightGroups.get(item.testFile) ?? [];
|
||||
list.push(item);
|
||||
playwrightGroups.set(item.testFile, list);
|
||||
}
|
||||
for (const [testFile, items] of playwrightGroups) {
|
||||
const pattern = `(?:${items.map((item) => item.testTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`;
|
||||
const result = runCheck(`npx playwright test --config playwright.config.ts ${testFile} --grep '${pattern}' --workers=1`, path.join(root, "web"));
|
||||
smokeReport.checks.push(result);
|
||||
for (const item of items) {
|
||||
const entry = smokeReport.cases.find((candidate) => candidate.featureId === item.featureId);
|
||||
entry.attempts = 1;
|
||||
entry.status = result.exitCode === 0 ? "PASS" : "FAIL";
|
||||
entry.evidence = { testFile, testTitle: item.testTitle, exitCode: result.exitCode };
|
||||
}
|
||||
flush();
|
||||
}
|
||||
for (const item of plan.cases.filter((candidate) => candidate.kind === "command")) {
|
||||
const result = runCheck(item.command);
|
||||
smokeReport.checks.push(result);
|
||||
const entry = smokeReport.cases.find((candidate) => candidate.featureId === item.featureId);
|
||||
entry.attempts = 1;
|
||||
entry.status = result.exitCode === 0 ? "PASS" : "FAIL";
|
||||
entry.evidence = { command: item.command, exitCode: result.exitCode };
|
||||
flush();
|
||||
}
|
||||
|
||||
const failed = smokeReport.cases.filter((item) => item.status === "FAIL");
|
||||
const pending = smokeReport.cases.filter((item) => item.status !== "PASS");
|
||||
smokeReport.status = failed.length ? "FAILED_FOCUSED_FIX_REQUIRED" : pending.length ? "BLOCKED" : "PASS";
|
||||
smokeReport.finishedAt = new Date().toISOString();
|
||||
smokeReport.summary = { total: smokeReport.cases.length, passed: smokeReport.cases.filter((item) => item.status === "PASS").length, failed: failed.length, pending: pending.length, retries: 0 };
|
||||
flush();
|
||||
process.stdout.write(`feature-smoke-${smokeReport.status.toLowerCase()} total=${smokeReport.summary.total} passed=${smokeReport.summary.passed} failed=${smokeReport.summary.failed} pending=${smokeReport.summary.pending} retries=0\n`);
|
||||
process.exit(failed.length || pending.length ? 1 : 0);
|
||||
Reference in New Issue
Block a user