#!/usr/bin/env python3 """Run all Chromium E2E configurations once, retaining bounded batch evidence.""" import argparse import fcntl import importlib.util import json import os from pathlib import Path import shutil import signal import socket import subprocess import time ROOT = Path(__file__).resolve().parents[2] BASE = ROOT / "tests/golden/WBV2-P1424-001" spec = importlib.util.spec_from_file_location("repairs", ROOT / "tools/web/run-wbv2-smoke-repairs.py") repairs = importlib.util.module_from_spec(spec) spec.loader.exec_module(repairs) now, write_json, artifact = repairs.now, repairs.write_json, repairs.artifact def rows(suites, source=""): for suite in suites: filename = suite.get("file", source) for case in suite.get("specs", []): for test in case.get("tests", []): yield {"spec": Path(case.get("file", filename)).name, "title": case["title"], "status": test.get("status"), "attempts": [{"status": r.get("status"), "duration": r.get("duration"), "errors": r.get("errors", [])} for r in test.get("results", [])]} yield from rows(suite.get("suites", []), filename) def bindings(archive): paths = set() for directory in ("web/app/src", "web/protocol", "web/tests/e2e", "web/tests/fixtures", "blender-5.2.0/source/blender/web_engine"): paths.update(p for p in (ROOT / directory).rglob("*") if p.is_file()) paths.update((ROOT / "web").glob("playwright*.config.ts")) paths.update(ROOT / p for p in ( "web/app/public/vendor/blender/web_engine.wasm", "web/app/public/vendor/blender/web_engine.js", "web/app/public/wasm-module-manifest.json", "web/package-lock.json", "tools/web/run-wbv2-full-smoke.py", "tools/web/run-wbv2-smoke-repairs.py", "tools/web/run-wbv2-smoke-closure.py", "tools/web/record-wbv2-full-smoke.py", "blender-5.2.0/source/blender/blenkernel/intern/idtype.cc", "blender-5.2.0/source/blender/blenkernel/intern/particle.cc", "blender-5.2.0/source/blender/modifiers/intern/MOD_particlesystem.cc", "tools/web/server-operator-job.py", "tools/web/server-operator-job-server.mjs")) paths.update(ROOT / name for name in json.loads(os.environ.get("WBV2_MODIFIER_BATCH_FIXTURES", "[]"))) paths.add(archive) return [artifact(p) for p in sorted(paths)] def run_group(name, config, timeout, output, env): folder = output / name folder.mkdir() with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] env = {**env, "WEB_TEST_PORT": str(port), "PLAYWRIGHT_JSON_OUTPUT_FILE": str(folder / "playwright.json"), "EDITING_SOAK_REPORT": str(folder / "soak.json")} command = [shutil.which("node"), str(ROOT / "web/node_modules/@playwright/test/cli.js"), "test", "--config", config, "--workers=1", "--retries=0", "--reporter=list,json", "--output", str(folder / "attachments")] result = {"group": name, "config": config, "command": command, "startedAt": now(), "status": "RUNNING", "port": port, "timeoutSeconds": timeout} write_json(folder / "result.json", result) print(f"START {name}: {folder}", flush=True) with (folder / "stdout.log").open("w") as stdout, (folder / "stderr.log").open("w") as stderr: process = subprocess.Popen(command, cwd=ROOT / "web", env=env, stdout=stdout, stderr=stderr, start_new_session=True) start = time.monotonic() try: while process.poll() is None: elapsed = time.monotonic() - start heartbeat = {"updatedAt": now(), "pid": os.getpid(), "childPid": process.pid, "group": name, "elapsedSeconds": round(elapsed), "status": "RUNNING", "output": str(output.relative_to(ROOT))} write_json(output / "heartbeat.json", heartbeat) print(json.dumps(heartbeat), flush=True) if elapsed >= timeout: result["status"] = "TIMEOUT" break try: process.wait(timeout=min(30, timeout - elapsed)) except subprocess.TimeoutExpired: pass else: result.update(exitCode=process.returncode, status="PASS" if process.returncode == 0 else "FAILED") except BaseException: result["status"] = "INTERRUPTED" raise finally: repairs.stop_group(process) result["finishedAt"] = now() write_json(folder / "result.json", result) report_path = folder / "playwright.json" cases = [] if report_path.is_file(): report = json.loads(report_path.read_text()) cases = list(rows(report.get("suites", []))) result.update(stats=report.get("stats", {}), errors=report.get("errors", []), tests=cases, report=artifact(report_path)) if result["status"] == "PASS" and (not cases or result.get("errors") or any(c["status"] != "expected" for c in cases)): result["status"] = "INCOMPLETE" result["logs"] = [artifact(folder / filename) for filename in ("stdout.log", "stderr.log")] write_json(folder / "result.json", result) print(f"END {name}: {result['status']} {result.get('stats', {})}", flush=True) return result def main(): parser = argparse.ArgumentParser() parser.add_argument("--archive", type=Path, default=BASE / "offline-test-bundle/blender-web-offline.tar.gz") args = parser.parse_args() lock = (BASE / "full-smoke.lock").open("a+") fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) output = BASE / "full-smoke" / repairs.datetime.now(repairs.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") output.mkdir(parents=True) archive = args.archive.resolve() env = {**os.environ, "CHROME_PATH": os.environ.get("CHROME_PATH") or shutil.which("google-chrome"), "BLENDER_BIN": str(Path(os.environ.get("BLENDER_BIN", ROOT / "build_blender_native/bin/blender")).resolve()), "M6_BINARY_ARCHIVE": str(archive), "EDITING_SOAK_DURATION_MS": "1800000", "EDITING_SOAK_ALLOW_SHORT": "0"} if not env["CHROME_PATH"] or not Path(env["BLENDER_BIN"]).is_file() or not archive.is_file(): raise RuntimeError("Local Chromium, Blender and production archive are required") initial = bindings(archive) write_json(output / "inputs.json", initial) expected_specs = sorted(p.name for p in (ROOT / "web/tests/e2e").rglob("*.spec.ts")) summary = {"task": "WBV2-P1424-001", "startedAt": now(), "status": "RUNNING", "groups": [], "expectedSpecs": expected_specs, "historicalSpecCount": 243, "batchEnvironment": {key: env.get(key) for key in ("WBV2_MODIFIER_BATCH_FIXTURES", "WBV2_MODIFIER_BATCH_TYPES", "WBV2_MODIFIER_BATCH_UNITS", "WBV2_MODIFIER_BATCH_PRODUCTION")}, "browser": "chromium", "retryPolicy": "No automatic retries; diagnose failures before a new batch"} write_json(output / "summary.json", summary) for name, config, timeout in (("main", "playwright.config.ts", 7200), ("archive", "playwright.archive.config.ts", 600), ("single-thread", "playwright.single-thread.config.ts", 300)): try: summary["groups"].append(run_group(name, config, timeout, output, env)) except BaseException as error: summary.update(status="INTERRUPTED", error=str(error), finishedAt=now()) write_json(output / "summary.json", summary) raise write_json(output / "summary.json", summary) cases = [case for group in summary["groups"] for case in group.get("tests", [])] by_spec = {filename: [case for case in cases if case["spec"] == filename] for filename in expected_specs} summary.update(finishedAt=now(), totalSpecs=len(expected_specs), totalTests=len(cases), passedTests=sum(case["status"] == "expected" for case in cases), passedSpecs=sum(bool(tests) and all(c["status"] == "expected" for c in tests) for tests in by_spec.values()), missingSpecs=[filename for filename, tests in by_spec.items() if not tests], failures=[case for case in cases if case["status"] != "expected"], inputsUnchanged=initial == bindings(archive), inputs=artifact(output / "inputs.json")) summary["status"] = "PASS" if (all(g["status"] == "PASS" for g in summary["groups"]) and not summary["missingSpecs"] and summary["inputsUnchanged"]) else "FAILED" write_json(output / "summary.json", summary) write_json(output / "heartbeat.json", {"updatedAt": now(), "status": summary["status"], "pid": os.getpid()}) write_json(BASE / "full-smoke-latest.json", {"status": summary["status"], "report": artifact(output / "summary.json")}) print(json.dumps({key: value for key, value in summary.items() if key not in ("groups", "expectedSpecs")}), flush=True) return 0 if summary["status"] == "PASS" else 1 if __name__ == "__main__": signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt("SIGTERM"))) raise SystemExit(main())