#!/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())