Files
workinf_Blender_Wasm/tools/web/run-wbv2-smoke-repairs.py
mes123456 f3e21c6b98
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
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

262 lines
15 KiB
Python

#!/usr/bin/env python3
"""Run Chromium repair cases with isolated reports and bounded process groups."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import signal
import socket
import subprocess
import time
from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parents[2]
DEFAULT_SPECS = (
"cross-browser", "dirty-state", "geometry-node-allowlist", "glb-export-loss-report",
"keyboard-accessibility", "recent-projects-recovery", "wbv2-audio-particle-production",
"wbv2-modifier-parameter-production", "wbv2-modifier-visibility-production",
"wbv2-module-matrix", "wbv2-native-engine-production", "smoke", "editing-soak",
)
SPECIAL_CONFIGS = {
"archive-offline": "playwright.archive.config.ts",
"single-thread-unisolated": "playwright.single-thread.config.ts",
}
def now():
return datetime.now(timezone.utc).isoformat()
def write_json(path, value):
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(value, indent=2) + "\n")
temporary.replace(path)
def artifact(path):
return {"path": str(path.relative_to(ROOT)), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
def stop_group(process):
try:
os.killpg(process.pid, signal.SIGTERM)
time.sleep(1)
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
def record_progress():
base = ROOT / "tests/golden/WBV2-P1424-001"
latest = {}
for path in sorted((base / "smoke-repairs").glob("*/*/result.json")):
entry = json.loads(path.read_text())
latest[entry["spec"].removesuffix(".spec.ts")] = (entry, path)
cases = []
for name in DEFAULT_SPECS:
if name not in latest:
cases.append({"spec": name + ".spec.ts", "status": "NOT_RUN"})
continue
entry, path = latest[name]
cases.append({"spec": entry["spec"], "status": entry["status"], "exitCode": entry.get("exitCode"),
"stats": entry.get("stats"), "receipt": artifact(path)})
catalog = json.loads((ROOT / "web/app/src/capabilities/blender-feature-route-catalog.json").read_text())
report = {
"task": "WBV2-P1424-001", "updatedAt": now(),
"scope": "13 requested failing E2E specs, Chromium only",
"status": "PASS" if all(c["status"] == "PASS" for c in cases) else "in_progress",
"passed": sum(c["status"] == "PASS" for c in cases), "total": len(cases), "cases": cases,
"fullDesktopParity": "NOT_ESTABLISHED",
"sourceInventory": {"source": "web/app/src/capabilities/blender-feature-route-catalog.json",
"unitCount": catalog["unitCount"],
"verifiedUnits": catalog["behavioralEvidence"]["verifiedUnits"],
"unverifiedUnits": catalog["behavioralEvidence"]["unverifiedUnits"],
"sourceTreeSha256": catalog["sourceTreeSha256"],
"boundUnitDelta": 0, "freshness": "existing catalog, not regenerated"},
"fixes": [
"Allocate BMesh operator tool flags before native primitive creation; repeated use previously corrupted memory.",
"Native serialized Sound values take precedence over retained fallback metadata.",
"Serialize registered ParticleSystemModifierData with Blender's native writer; keep rejecting missing or generic modifier layouts.",
"Use Object mode for object creation and Layout Timeline before switching to Modeling in UI tests.",
"Style Physics Add button with explicit accessible foreground/background colors.",
"Assert supported Geometry Nodes commit/save/reopen instead of expecting the historical missing evaluator.",
"Native cubes have six polygon faces, so two cubes produce 12 faces.",
"Skin GLB Icosphere inherits hidden collection visibility; Blender visible_get() is false. BIN payload unchanged.",
"Independent ports, complete logs, JSON reports and process-group cleanup; 20-minute smoke and 40-minute formal-soak outer budgets.",
],
"artifacts": [artifact(ROOT / p) for p in (
"blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp",
"web/app/src/workers/web-engine.worker.ts", "web/app/src/app/PhysicsSettingsEditor.css",
"web/app/public/vendor/blender/web_engine.wasm", "web/app/public/vendor/blender/web_engine.js",
"web/app/public/wasm-module-manifest.json", "tools/web/run-wbv2-smoke-repairs.py",
"web/tests/e2e/native-main-lifecycle.spec.ts", "tests/golden/M12-06D/web-loss-report.json",
"tests/files/web/m12_glb_web_v1/skin.glb")],
}
write_json(base / "smoke-repair-report.json", report)
product_path = base / "product-implementation-report.json"
product = json.loads(product_path.read_text())
product["status"] = "in_progress"
product["smokeRepairs"] = {"status": report["status"], "passed": report["passed"], "total": report["total"],
"report": artifact(base / "smoke-repair-report.json")}
product["completionScope"] = "Targeted captures and smoke coverage do not establish full desktop parity."
# Preserve the original batch result; repair receipts are a separate scoped update.
product["fullE2ESmoke"]["repairStatus"] = report["status"]
product["fullE2ESmoke"]["repairReport"] = artifact(base / "smoke-repair-report.json")
product["fullE2ESmoke"]["historicalCountsScope"] = "Original full batch; see repairReport for current results on the 13 requested specs."
write_json(product_path, product)
status_path = ROOT / "docs/status/WBV2-P1424-001.md"
status_path.write_text("# WBV2-P1424-001 status\n\n- status: `in_progress`\n"
f"- requested smoke repairs: {report['passed']}/{report['total']} passed\n"
"- full desktop parity: not established\n- nextTask: `null`\n"
"- details: `tests/golden/WBV2-P1424-001/smoke-repair-report.json`\n")
manifest_path = base / "manifest.json"
manifest = json.loads(manifest_path.read_text())
manifest["status"] = "in_progress"
for path in (base / "smoke-repair-report.json", product_path, status_path):
ref = artifact(path)
manifest["artifacts"][ref["path"]] = {"sha256": ref["sha256"], "bytes": path.stat().st_size}
write_json(manifest_path, manifest)
print(json.dumps({"report": str(base / "smoke-repair-report.json"), "passed": report["passed"], "total": report["total"]}), flush=True)
def record_special_progress():
base = ROOT / "tests/golden/WBV2-P1424-001"
latest = {}
for path in sorted((base / "smoke-repairs").glob("*/*/result.json")):
entry = json.loads(path.read_text())
name = entry["spec"].removesuffix(".spec.ts")
if name in SPECIAL_CONFIGS:
latest[name] = {"spec": entry["spec"], "status": entry["status"],
"exitCode": entry.get("exitCode"), "stats": entry.get("stats", {}),
"receipt": artifact(path), "config": entry.get("config"),
**({"archive": entry["archive"]} if "archive" in entry else {})}
cases = [latest.get(name, {"spec": name + ".spec.ts", "status": "NOT_RUN"}) for name in SPECIAL_CONFIGS]
passed = sum(c["status"] == "PASS" for c in cases)
catalog = json.loads((ROOT / "web/app/src/capabilities/blender-feature-route-catalog.json").read_text())
report = {
"schemaVersion": 1, "task": "WBV2-P1424-001", "updatedAt": now(),
"scope": "Offline archive and non-isolated single-thread Chromium configurations",
"status": "PASS" if passed == len(cases) else "in_progress", "passed": passed, "total": len(cases),
"cases": cases, "testsPassed": sum(c.get("stats", {}).get("expected", 0) for c in cases),
"sourceInventory": {"unitCount": catalog["unitCount"],
"verifiedUnits": catalog["behavioralEvidence"]["verifiedUnits"],
"unverifiedUnits": catalog["behavioralEvidence"]["unverifiedUnits"],
"sourceTreeSha256": catalog["sourceTreeSha256"], "boundUnitDelta": 0,
"freshness": "existing catalog, not regenerated"},
"coverage": {
"offline": ["extracted production archive served outside checkout", "external HTTP requests blocked",
"cold boot", "native object editing", "undo/redo", "OPFS hash-bound save/reopen", "GLB export"],
"singleThread": ["COOP/COEP/CORP absent", "crossOriginIsolated false", "SharedArrayBuffer unavailable",
"AUTO selects single", "pthread requests absent", "native scene edit"],
},
"artifacts": [artifact(ROOT / p) for p in (
"tools/web/run-wbv2-smoke-repairs.py", "tools/web/create-offline-release.mjs",
"web/tests/e2e/archive-offline.spec.ts", "web/tests/e2e/single-thread-unisolated.spec.ts",
"web/app/public/wasm-module-manifest.json", "web/app/public/vendor/blender/web_engine.wasm")],
"limitations": ["Loopback HTTP remains available for the offline archive; file:// is not supported.",
"SERVER_NATIVE services are outside this offline workflow.",
"These two configurations do not constitute a rerun of the entire E2E suite or desktop parity."],
}
report_path = base / "special-e2e-report.json"
write_json(report_path, report)
product_path = base / "product-implementation-report.json"
product = json.loads(product_path.read_text())
product["status"] = "in_progress"
product["specialE2ESmoke"] = {"status": report["status"], "passed": passed, "total": len(cases), "report": artifact(report_path)}
write_json(product_path, product)
status_path = ROOT / "docs/status/WBV2-P1424-001.md"
lines = [line for line in status_path.read_text().splitlines() if not line.startswith("- special E2E:")]
lines.append(f"- special E2E: {passed}/{len(cases)} passed; `tests/golden/WBV2-P1424-001/special-e2e-report.json`")
status_path.write_text("\n".join(lines) + "\n")
manifest_path = base / "manifest.json"
manifest = json.loads(manifest_path.read_text())
manifest["status"] = "in_progress"
for path in (report_path, product_path, status_path):
ref = artifact(path)
manifest["artifacts"][ref["path"]] = {"sha256": ref["sha256"], "bytes": path.stat().st_size}
write_json(manifest_path, manifest)
print(json.dumps({"report": str(report_path), "passed": passed, "total": len(cases)}), flush=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("specs", nargs="*", default=list(DEFAULT_SPECS))
parser.add_argument("--record", action="store_true", help="Record latest repair receipts without running tests")
parser.add_argument("--record-special", action="store_true", help="Record the two dedicated configurations without running tests")
args = parser.parse_args()
if args.record_special:
record_special_progress()
return 0
if args.record:
record_progress()
return 0
output = ROOT / "tests/golden/WBV2-P1424-001/smoke-repairs" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
output.mkdir(parents=True)
chrome = os.environ.get("CHROME_PATH") or shutil.which("google-chrome")
node = shutil.which("node")
blender = Path(os.environ.get("BLENDER_BIN", ROOT / "build_blender_native/bin/blender")).resolve()
if not chrome or not node or not blender.is_file():
raise RuntimeError("Local Chromium, Node and Blender binaries are required")
results = []
print(f"Reports: {output}", flush=True)
for spec in args.specs:
name = spec.removesuffix(".spec.ts")
source = ROOT / "web/tests/e2e" / f"{name}.spec.ts"
if not source.is_file() or source.parent != ROOT / "web/tests/e2e":
raise ValueError(f"Unknown E2E spec: {spec}")
case = output / name
case.mkdir()
with socket.socket() as port_socket:
port_socket.bind(("127.0.0.1", 0))
port = port_socket.getsockname()[1]
env = {**os.environ, "CHROME_PATH": chrome, "BLENDER_BIN": str(blender),
"WEB_TEST_PORT": str(port), "PLAYWRIGHT_JSON_OUTPUT_FILE": str(case / "playwright.json"),
"EDITING_SOAK_DURATION_MS": "1800000", "EDITING_SOAK_ALLOW_SHORT": "0",
"EDITING_SOAK_REPORT": str(case / "soak.json")}
config = SPECIAL_CONFIGS.get(name, "playwright.config.ts")
command = [node, str(ROOT / "web/node_modules/@playwright/test/cli.js"), "test",
"--config", config, "--workers=1", "--reporter=list,json",
"--output", str(case / "attachments"), f"tests/e2e/{name}.spec.ts"]
timeout = 2400 if name == "editing-soak" else 1200 if name == "smoke" else 300
result = {"spec": source.name, "command": command, "cwd": str(ROOT / "web"),
"startedAt": now(), "status": "RUNNING", "port": port, "timeoutSeconds": timeout,
"source": artifact(source), "browser": "chromium", "blender": str(blender)}
result["config"] = artifact(ROOT / "web" / config)
if name == "archive-offline":
archive = Path(env.get("M6_BINARY_ARCHIVE", ROOT / "release/blender-web-offline.tar.gz")).resolve()
env["M6_BINARY_ARCHIVE"] = str(archive)
result["archive"] = artifact(archive)
write_json(case / "result.json", result)
print(f"START {name} port={port} budget={timeout}s", flush=True)
with (case / "stdout.log").open("w") as stdout, (case / "stderr.log").open("w") as stderr:
process = subprocess.Popen(command, cwd=ROOT / "web", env=env, stdout=stdout, stderr=stderr, start_new_session=True)
try:
code = process.wait(timeout=timeout)
result.update(status="PASS" if code == 0 else "FAILED", exitCode=code)
except subprocess.TimeoutExpired:
result.update(status="TIMEOUT", exitCode=None)
finally:
stop_group(process)
report_path = case / "playwright.json"
if report_path.exists():
report = json.loads(report_path.read_text())
result["stats"] = report.get("stats", {})
if result["status"] == "PASS" and (not result["stats"].get("expected") or result["stats"].get("unexpected") or report.get("errors")):
result["status"] = "INCOMPLETE"
elif result["status"] == "PASS":
result["status"] = "INCOMPLETE"
result.update(finishedAt=now(), artifacts=[artifact(p) for p in sorted(case.rglob("*")) if p.is_file() and p.name != "result.json"])
write_json(case / "result.json", result)
results.append(result)
write_json(output / "summary.json", {"status": "in_progress", "results": results, "remaining": args.specs[len(results):]})
print(f"END {name}: {result['status']} {result.get('stats', {})}", flush=True)
write_json(output / "summary.json", {"status": "PASS" if all(r["status"] == "PASS" for r in results) else "FAILED", "results": results, "remaining": []})
return 0 if all(r["status"] == "PASS" for r in results) else 1
if __name__ == "__main__":
raise SystemExit(main())