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>
262 lines
15 KiB
Python
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())
|