test(web): add one-pass feature smoke plan and record blockers
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-09-07 06:48:03 +08:00
parent 91cb389a82
commit 0bc56836c5
15 changed files with 1702 additions and 22 deletions

View 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`);

View 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`);

View 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);