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>
165 lines
9.2 KiB
Python
165 lines
9.2 KiB
Python
#!/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())
|