summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 02:03:45 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 02:07:13 +0800
commitdd75aaf6e82799ce88d8e66b01a3d53ba05301c9 (patch)
treeef1601be2954f7c995f5fd47544241b457a7791e /tools
parent7e7166e9a7dfe6d619dbb2d3baa54ee97dfce1b9 (diff)
downloadsomhairles-dream-fsharp-dd75aaf6e82799ce88d8e66b01a3d53ba05301c9.tar.gz
feat(viewer): fit camera to loaded model and add checked-in browser acceptance driver
- add three.js Box3/Camera bindings and bbox-driven camera framing (fov-fit with margin, recenters the model so group rotation orbits in place); refit on canvas resize instead of reverting to replay placeholder geometry - expose window.SOMHAIRLES_VIEWER debug handle for numeric verification - add tools/acceptance/e2e_acceptance.py: self-provisioning playwright driver (isolated port/artifact root, unique run ids) asserting framing, frozen-screenshot determinism, GLB hash equality, failure surfacing - gitignore scratch/ and tools/acceptance/deps/; document the clean-checkout verification sequence (fable before build)
Diffstat (limited to 'tools')
-rwxr-xr-xtools/acceptance/e2e_acceptance.py351
1 files changed, 351 insertions, 0 deletions
diff --git a/tools/acceptance/e2e_acceptance.py b/tools/acceptance/e2e_acceptance.py
new file mode 100755
index 0000000..f792923
--- /dev/null
+++ b/tools/acceptance/e2e_acceptance.py
@@ -0,0 +1,351 @@
+#!/usr/bin/env python3
+"""Checked-in browser acceptance driver for the live pipeline viewport.
+
+Starts a Release server on an isolated port with its own artifact root, drives
+a real chromium browser through a live pipeline run (desktop and narrow
+viewports), and asserts actual on-screen geometry/framing via the projected
+bounding box of the loaded GLB - not just labels or whole-canvas hashes.
+
+Playwright is provisioned into tools/acceptance/deps (repo-local, never
+system/user site-packages). First run may download chromium into the default
+user browser cache via `playwright install chromium`.
+
+Usage:
+ python3 tools/acceptance/e2e_acceptance.py [--port 18201]
+ [--artifact-root DIR] [--evidence-dir DIR] [--stage-root DIR]
+ [--server-dll PATH] [--bpython PATH] [--keep-server]
+
+Preconditions:
+ dotnet tool restore (if tools are pinned in .config/dotnet-tools.json)
+ dotnet fable src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj --outDir public
+ dotnet build SomhairlesDream.sln -c Release
+ real bpy interpreter available (default /tmp/bpyenv/bin/python)
+"""
+
+import argparse
+import hashlib
+import json
+import os
+import shutil
+import signal
+import socket
+import subprocess
+import sys
+import time
+import urllib.parse
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+
+TOOLS_DIR = Path(__file__).resolve().parent
+REPO = TOOLS_DIR.parents[1]
+DEPS = TOOLS_DIR / "deps"
+DEFAULT_BPYTHON = Path("/tmp/bpyenv/bin/python")
+DEFAULT_SERVER_DLL = (
+ REPO / "src" / "SomhairlesDream.Server" / "bin" / "Release" / "net8.0" / "SomhairlesDream.Server.dll"
+)
+
+REFERENCE_SHA = {
+ "steps/01-foundation.glb": "6082e735a65557665f68e673bd2899a38818d57a09e244a63fa3fb7f3cc687ef",
+ "steps/03-cabin.glb": "4c54f96c66cdb6ad56e2d4118a90e2d6507a2dc061c941867bda86e4a177a9b2",
+}
+
+GEOMETRY_JS = """
+() => {
+ const v = window.SOMHAIRLES_VIEWER;
+ if (!v || !v.camera || !v.group || !v.canvas) {
+ return { ready: false, reason: 'viewer handle missing' };
+ }
+ const w = v.canvas.clientWidth, h = v.canvas.clientHeight;
+ if (!w || !h) return { ready: false, reason: 'canvas has no size' };
+ const box = new THREE.Box3().setFromObject(v.group);
+ if (box.isEmpty()) return { ready: false, reason: 'empty bounding box' };
+ const size = new THREE.Vector3(); box.getSize(size);
+ const lo = box.min, hi = box.max;
+ const corners = [
+ [lo.x,lo.y,lo.z],[hi.x,lo.y,lo.z],[lo.x,hi.y,lo.z],[hi.x,hi.y,lo.z],
+ [lo.x,lo.y,hi.z],[hi.x,lo.y,hi.z],[lo.x,hi.y,hi.z],[hi.x,hi.y,hi.z]
+ ].map((p) => new THREE.Vector3(p[0], p[1], p[2]).project(v.camera));
+ const px = corners.map((c) => [(c.x * 0.5 + 0.5) * w, (0.5 - c.y * 0.5) * h]);
+ const xs = px.map((p) => p[0]);
+ const ys = px.map((p) => p[1]);
+ const minX = Math.min(...xs), maxX = Math.max(...xs);
+ const minY = Math.min(...ys), maxY = Math.max(...ys);
+ const minDim = Math.min(w, h);
+ const extent = Math.max(maxX - minX, maxY - minY);
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
+ return {
+ ready: true,
+ canvas: [w, h],
+ innerMargin: Math.min(minX, minY, w - maxX, h - maxY),
+ coverage: extent / minDim,
+ centerOffset: Math.hypot(cx - w / 2, cy - h / 2) / minDim,
+ boxSize: [size.x, size.y, size.z],
+ childCount: v.group.children.length
+ };
+}
+"""
+
+
+def log(msg):
+ print(f"[acceptance] {msg}", flush=True)
+
+
+def sha256_bytes(data):
+ return hashlib.sha256(data).hexdigest()
+
+
+def ensure_playwright():
+ if not (DEPS / "playwright").exists():
+ log("provisioning playwright into tools/acceptance/deps (repo-local, not system-wide)")
+ pinned = ""
+ try:
+ probe = subprocess.run(
+ [sys.executable, "-c", "import playwright,sys; sys.stdout.write(playwright.__version__)"],
+ capture_output=True, text=True, timeout=60,
+ )
+ if probe.returncode == 0 and probe.stdout.strip():
+ pinned = f"=={probe.stdout.strip()}"
+ log(f"pinning playwright{pinned} to reuse any existing chromium cache")
+ except Exception:
+ pass
+ DEPS.mkdir(parents=True, exist_ok=True)
+ subprocess.run(
+ [sys.executable, "-m", "pip", "install", "--target", str(DEPS),
+ "--break-system-packages", f"playwright{pinned}"],
+ check=True,
+ )
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(DEPS)
+ log("ensuring chromium browser is present (no-op if already downloaded)")
+ subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"],
+ env=env, check=True)
+ if str(DEPS) not in sys.path:
+ sys.path.insert(0, str(DEPS))
+ from playwright.sync_api import sync_playwright
+
+ return sync_playwright
+
+
+def wait_for_port(port, timeout=45.0):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ with socket.create_connection(("127.0.0.1", port), timeout=1.0):
+ return
+ except OSError:
+ time.sleep(0.3)
+ raise RuntimeError(f"server did not open port {port} within {timeout}s")
+
+
+def fetch_bytes(url):
+ with urllib.request.urlopen(url, timeout=30) as resp:
+ return resp.status, resp.headers.get("Content-Type", ""), resp.read()
+
+
+def wait_text(page, selector, needle, timeout_ms=90000):
+ page.wait_for_function(
+ "([sel, needle]) => (document.querySelector(sel)?.textContent ?? '').includes(needle)",
+ arg=[selector, needle],
+ timeout=timeout_ms,
+ )
+
+
+def assert_geometry_fit(geo, label, results):
+ checks = []
+ if not geo.get("ready"):
+ results.append((False, f"{label}: geometry probe not ready ({geo.get('reason')})"))
+ return checks
+ w, h = geo["canvas"]
+ min_dim = min(w, h)
+ checks.append(("childCount", geo["childCount"] >= 1, geo))
+ checks.append(("innerMargin>=2%minDim", geo["innerMargin"] >= 0.02 * min_dim, geo))
+ checks.append(("coverage in [0.40,0.95]", 0.40 <= geo["coverage"] <= 0.95, geo))
+ checks.append(("centerOffset<=10%minDim", geo["centerOffset"] <= 0.10, geo))
+ sizes = geo["boxSize"]
+ finite_pos = all(isinstance(s, (int, float)) and s > 0 and s == s and s != float("inf") for s in sizes)
+ checks.append(("boxSize finite>0", finite_pos, geo))
+ for name, ok, payload in checks:
+ results.append((ok, f"{label}: {name} -> {json.dumps(payload) if not ok else 'ok'}"))
+ return checks
+
+
+def run_flow(page, base_url, run_id, viewport_label, results, evidence, shot_prefix):
+ page.goto(f"{base_url}/?freeze=1", wait_until="domcontentloaded")
+ wait_text(page, "#status-label", "待机")
+ results.append((True, f"{viewport_label}: idle 待机 visible"))
+ body = page.inner_text("body")
+ results.append(("当前没有下一版正在开发" in body, f"{viewport_label}: empty-state text present"))
+
+ page.fill("#run-id-input", run_id)
+ render_box = page.query_selector("#run-render-input")
+ if render_box and render_box.is_checked():
+ render_box.set_checked(False)
+ page.click("#run-start-button")
+
+ wait_text(page, "#stream-state", "已连接")
+ results.append((True, f"{viewport_label}: SSE 已连接"))
+
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ wait_text(page, "#status-label", "运行中")
+ results.append((True, f"{viewport_label}: stage1 live (运行中 + steps/01-foundation.glb)"))
+ page.wait_for_timeout(600)
+ geo1 = page.evaluate(GEOMETRY_JS)
+ (evidence / f"{shot_prefix}-geo-stage1.json").write_text(json.dumps(geo1, indent=2))
+ assert_geometry_fit(geo1, f"{viewport_label}/stage1", results)
+ page.locator("#viewport-canvas").screenshot(path=str(evidence / f"{shot_prefix}-canvas-stage1.png"))
+ page.screenshot(path=str(evidence / f"{shot_prefix}-page-stage1.png"), full_page=True)
+
+ wait_text(page, "#viewport-note", "管线几何 · steps/03-cabin.glb")
+ wait_text(page, "#status-label", "已完成")
+ results.append((True, f"{viewport_label}: stage3 complete (已完成 + steps/03-cabin.glb)"))
+ page.wait_for_timeout(600)
+ geo3 = page.evaluate(GEOMETRY_JS)
+ (evidence / f"{shot_prefix}-geo-stage3.json").write_text(json.dumps(geo3, indent=2))
+ assert_geometry_fit(geo3, f"{viewport_label}/stage3", results)
+ page.locator("#viewport-canvas").screenshot(path=str(evidence / f"{shot_prefix}-canvas-stage3.png"))
+
+ if geo1.get("ready") and geo3.get("ready"):
+ diffs = [abs(a - b) / max(abs(a), abs(b), 1e-9) for a, b in zip(geo1["boxSize"], geo3["boxSize"])]
+ results.append((max(diffs) >= 0.05,
+ f"{viewport_label}: stage1 vs stage3 bbox differs (max rel diff {max(diffs):.3f})"))
+
+ shot3 = evidence / f"{shot_prefix}-canvas-stage3.png"
+ page.wait_for_timeout(800)
+ repeat = evidence / f"{shot_prefix}-canvas-stage3-repeat.png"
+ page.locator("#viewport-canvas").screenshot(path=str(repeat))
+ results.append((sha256_bytes(shot3.read_bytes()) == sha256_bytes(repeat.read_bytes()),
+ f"{viewport_label}: frozen stage3 screenshot deterministic"))
+ page.screenshot(path=str(evidence / f"{shot_prefix}-page-stage3.png"), full_page=True)
+
+ href = page.get_attribute("#artifact-manifest-link", "href")
+ results.append((bool(href), f"{viewport_label}: manifest link exposed ({href})"))
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("--port", type=int, default=18201)
+ parser.add_argument("--artifact-root", default=str(REPO / "scratch" / "acceptance" / "artifacts"))
+ parser.add_argument("--evidence-dir", default=None)
+ parser.add_argument("--stage-root", default=str(REPO / "stage"))
+ parser.add_argument("--server-dll", default=str(DEFAULT_SERVER_DLL))
+ parser.add_argument("--bpython", default=str(DEFAULT_BPYTHON))
+ parser.add_argument("--keep-server", action="store_true")
+ args = parser.parse_args()
+
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ evidence = Path(args.evidence_dir or (REPO / "scratch" / "acceptance" / f"evidence-{stamp}"))
+ evidence.mkdir(parents=True, exist_ok=True)
+ artifact_root = Path(args.artifact_root)
+ artifact_root.mkdir(parents=True, exist_ok=True)
+ server_log = evidence / "server.log"
+
+ if not Path(args.server_dll).exists():
+ log(f"server dll missing: {args.server_dll} - run 'dotnet build SomhairlesDream.sln -c Release' first")
+ return 2
+ if not Path(args.bpython).exists():
+ log(f"bpy interpreter missing: {args.bpython} (pass --bpython)")
+ return 2
+ try:
+ with socket.create_connection(("127.0.0.1", args.port), timeout=0.5):
+ log(f"port {args.port} already in use - choose another --port")
+ return 2
+ except OSError:
+ pass
+
+ env = os.environ.copy()
+ env["SOMHAIRLES_ARTIFACT_ROOT"] = str(artifact_root)
+ env["SOMHAIRLES_STAGE_ROOT"] = str(args.stage_root)
+ env["SOMHAIRLES_BPYTHON"] = str(args.bpython)
+ server = subprocess.Popen(
+ ["dotnet", args.server_dll, "--urls", f"http://127.0.0.1:{args.port}"],
+ env=env, stdout=open(server_log, "wb"), stderr=subprocess.STDOUT,
+ start_new_session=True, cwd=str(REPO),
+ )
+ base_url = f"http://127.0.0.1:{args.port}"
+ results = []
+ console_errors = []
+
+ try:
+ wait_for_port(args.port)
+ with urllib.request.urlopen(f"{base_url}/", timeout=15) as resp:
+ results.append((resp.status == 200, f"GET / -> {resp.status}"))
+ sync_playwright = ensure_playwright()
+ with sync_playwright() as pw:
+ browser = pw.chromium.launch(args=["--enable-unsafe-swiftshader"])
+
+ def track(page):
+ page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
+ page.on("pageerror", lambda e: console_errors.append(str(e)))
+
+ desktop_ms = int(time.time() * 1000)
+ page = browser.new_page(viewport={"width": 1440, "height": 1000})
+ track(page)
+ run_id = f"run-acc-{desktop_ms}-desktop"
+ run_flow(page, base_url, run_id, "desktop", results, evidence, "desktop")
+ project_id = page.input_value("#project-id-input")
+
+ narrow_ms = int(time.time() * 1000) + 1
+ page2 = browser.new_page(viewport={"width": 390, "height": 844})
+ track(page2)
+ run_id2 = f"run-acc-{narrow_ms}-narrow"
+ run_flow(page2, base_url, run_id2, "narrow", results, evidence, "narrow")
+
+ for step_path in ("steps/01-foundation.glb", "steps/03-cabin.glb"):
+ q = urllib.parse.urlencode({
+ "projectId": project_id, "runId": run_id, "path": step_path,
+ })
+ status, ctype, body = fetch_bytes(f"{base_url}/api/artifacts/steps?{q}")
+ digest = sha256_bytes(body)
+ results.append((status == 200, f"GET steps {step_path} -> {status}"))
+ results.append(("model/gltf-binary" in ctype, f"content-type {step_path} -> {ctype}"))
+ expected = REFERENCE_SHA[step_path]
+ results.append((digest == expected, f"sha256 {step_path} -> {digest[:12]}… (expect {expected[:12]}…)"))
+
+ fail_ms = int(time.time() * 1000) + 2
+ fail_run_id = f"run-acc-{fail_ms}-failure"
+ (artifact_root / project_id / fail_run_id).mkdir(parents=True, exist_ok=True)
+ page.fill("#run-id-input", fail_run_id)
+ page.click("#run-start-button")
+ wait_text(page, "#status-label", "失败")
+ msg = page.inner_text("#run-message")
+ results.append(("run directory already exists" in msg,
+ f"failure run surfaces pipeline error (message: {msg.strip()[:80]}…)"))
+
+ browser.close()
+ finally:
+ if not args.keep_server:
+ try:
+ os.killpg(os.getpgid(server.pid), signal.SIGTERM)
+ except ProcessLookupError:
+ pass
+ try:
+ server.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ os.killpg(os.getpgid(server.pid), signal.SIGKILL)
+ else:
+ log(f"server kept alive on port {args.port} (pid {server.pid})")
+
+ if console_errors:
+ results.append((False, f"console/page errors: {console_errors[:5]}"))
+ else:
+ results.append((True, "zero browser console/page errors"))
+
+ passed = sum(1 for ok, _ in results if ok)
+ failed = len(results) - passed
+ report = {
+ "timestamp": stamp,
+ "base_url": base_url,
+ "evidence_dir": str(evidence),
+ "console_errors": console_errors,
+ "checks": [{"ok": ok, "detail": detail} for ok, detail in results],
+ }
+ (evidence / "acceptance-report.json").write_text(json.dumps(report, indent=2, ensure_ascii=False))
+ for ok, detail in results:
+ log(f"{'PASS' if ok else 'FAIL'}: {detail}")
+ log(f"RESULT: {passed}/{len(results)} checks passed ({failed} failed); evidence in {evidence}")
+ return 0 if failed == 0 else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())