diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | README.md | 63 | ||||
| -rw-r--r-- | public/App.js | 44 | ||||
| -rw-r--r-- | src/SomhairlesDream.Frontend/App.fs | 48 | ||||
| -rw-r--r-- | src/SomhairlesDream.Frontend/Bindings.fs | 28 | ||||
| -rwxr-xr-x | tools/acceptance/e2e_acceptance.py | 351 |
6 files changed, 528 insertions, 8 deletions
@@ -12,3 +12,5 @@ dist/ __pycache__/ *.pyc .venv/ +tools/acceptance/deps/ +scratch/ @@ -45,6 +45,58 @@ Three.js r160 and GLTFLoader are vendored under `public/vendor/` (import map plus a `three-setup.js` shim exposing `window.THREE`/`window.GLTFLoader`). They are MIT-licensed third-party dependencies, not project assets. +## Browser acceptance driver + +`tools/acceptance/e2e_acceptance.py` is the checked-in end-to-end gate. It +starts the server on an isolated port with its own artifact root, launches +headless chromium, and drives a full run through the page form twice +(desktop 1440x1000 and narrow 390x844 viewports, `?freeze=1`), plus a +failure-path run. It fails unless every check passes; exit code 0 means +green. Checks include: + +- idle page (`待机`, empty-state text), SSE connection, live per-stage + viewport ingestion (`运行中` + `steps/01-foundation.glb`, then `已完成` + + `steps/03-cabin.glb`), manifest link exposure +- geometry framing asserted from the live scene (`window.SOMHAIRLES_VIEWER` + + `THREE.Box3` projected to screen space): model fully inside the canvas + with margins >= 2% of the smaller canvas dimension, pixel coverage within + 40%-95%, center offset <= 10%, on both viewports; stage-1 vs stage-3 + bounding boxes must differ by >= 5% +- frozen-viewport determinism: two consecutive canvas screenshots are + byte-identical (sha256) +- served GLB sha256 equality with pinned reference hashes + (`01-foundation`, `03-cabin`) and `model/gltf-binary` content type +- failure run (pre-existing run directory) surfaces `失败` and the pipeline + error in `run-message`; zero browser console/page errors overall + +Playwright is self-provisioned into the gitignored +`tools/acceptance/deps/` directory (`pip --target --break-system-packages`, +pinned to an already-installed version when detectable) so the driver never +installs into system site-packages; the chromium browser binary is reused +from the user cache when present. + +Full verification from a clean checkout: + +```sh +dotnet tool restore +# Fable must run BEFORE the solution build: the server serves the frontend +# from public/ (copied into wwwroot at build time), and a fresh checkout has +# no fable_modules/App.js yet. Also run the frontend project restore first, +# and use --noCache: fable's project-options cache (keyed by fsproj content) +# is shared across checkouts of the same project and can yield spurious +# "Fable namespace not defined" errors on a fresh clone. +dotnet restore src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj +dotnet fable src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj \ + --outDir public --noRestore --noCache +dotnet build SomhairlesDream.sln -c Release +dotnet test SomhairlesDream.sln -c Release --no-build +python3 tools/acceptance/e2e_acceptance.py --port 18201 \ + --bpython /tmp/bpyenv/bin/python +``` + +`--bpython` must point at a Python that can `import bpy` (see below); the +driver refuses to start without it. + ## Server ```sh @@ -130,9 +182,12 @@ dotnet run --project src/SomhairlesDream.Cli -- run \ reference run; a failure run (pre-existing run directory) shows status `失败` with the error surfaced in `run-message`; zero console errors on all pages. -- Frozen-viewport evidence (`?freeze=1` stops rotation): canvas screenshots - of placeholder vs stage-1 vs stage-3 differ in pixel hashes, and the three - stages are visually distinct cubes/frames/house. Evidence lives in a - scratch directory and is not committed. +- Browser acceptance driver (see above), 45/45 checks green on desktop and + narrow viewports: framing asserted numerically from the projected scene + bounding box (not by trusting whole-canvas hashes alone), frozen + screenshots byte-deterministic, GLB hash equality, failure surfacing, + zero console errors. The frontend refits the camera to the loaded model + (`THREE.Box3`-based framing) whenever the canvas resizes, and a resize + never reverts the viewport to replay placeholder geometry. - NOT verified: production deployment of the old site remains untouched; `--render` PNG renders; replay button flow against a historical run. diff --git a/public/App.js b/public/App.js index c5a5418..f953832 100644 --- a/public/App.js +++ b/public/App.js @@ -58,6 +58,8 @@ export let currentRunId = createAtom(""); export let loadedStepPath = createAtom(undefined); +export let displayedNode = createAtom(undefined); + export let freezeRotation = createAtom(false); export function element(id) { @@ -173,6 +175,7 @@ export function addMesh(view, frame) { export function renderFrame(frame) { selectedFrame(frame); + displayedNode(undefined); if (viewState() != null) { const view = viewState(); view.Group.clear(); @@ -181,11 +184,46 @@ export function renderFrame(frame) { } } +export function frameObject(view, node) { + const box = new THREE.Box3(); + box.setFromObject(node); + if (!box.isEmpty()) { + const center = new THREE.Vector3(); + const size = new THREE.Vector3(); + box.getCenter(center); + box.getSize(size); + const radius = 0.5 * size.length(); + if (radius > 1E-06) { + node.position.set(-center.x, -center.y, -center.z); + const camera = view.Camera; + const aspect = max(1, view.Canvas.clientWidth) / max(1, view.Canvas.clientHeight); + camera.aspect = aspect; + const fovY = (camera.fov * 3.141592653589793) / 180; + const limitingFov = min(fovY, 2 * Math.atan(Math.tan(fovY / 2) * aspect)); + const distance = (1.28 * radius) / Math.sin(limitingFov / 2); + const norm = Math.sqrt(((0.62 * 0.62) + (0.45 * 0.45)) + (0.7 * 0.7)); + camera.position.set((0.62 / norm) * distance, (0.45 / norm) * distance, (0.7 / norm) * distance); + camera.near = max(distance * 0.01, distance - (radius * 2.5)); + camera.far = (distance + (radius * 4)); + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + } + } +} + export function resizeView(view) { const width = max(1, view.Canvas.clientWidth); const height = max(1, view.Canvas.clientHeight); view.Renderer.setSize(width, height, false); - renderFrame(selectedFrame()); + view.Camera.aspect = (width / height); + view.Camera.updateProjectionMatrix(); + if (displayedNode() == null) { + renderFrame(selectedFrame()); + } + else { + frameObject(view, displayedNode()); + view.Renderer.render(view.Scene, view.Camera); + } } export function initializeView() { @@ -213,6 +251,7 @@ export function initializeView() { const view = new ViewState(renderer, scene, camera, group, canvas); viewState(view); freezeRotation((new URLSearchParams(window.location.search).get("freeze")) === "1"); + window.SOMHAIRLES_VIEWER = { camera: camera, scene: scene, group: group, canvas: canvas }; renderFrame(selectedFrame()); window.addEventListener("resize", (_arg) => { resizeView(view); @@ -262,6 +301,9 @@ export function loadStepArtifact(view, stepPath) { loader.load(url, ((gltf) => { view.Group.clear(); view.Group.add(gltf.scene); + displayedNode(gltf.scene); + frameObject(view, gltf.scene); + view.Renderer.render(view.Scene, view.Camera); setText("viewport-note", toText(printf("管线几何 · %s"))(stepPath)); }), null, ((_arg) => { setText("viewport-note", toText(printf("管线几何加载失败 · %s"))(stepPath)); diff --git a/src/SomhairlesDream.Frontend/App.fs b/src/SomhairlesDream.Frontend/App.fs index fa52a9f..64fb61f 100644 --- a/src/SomhairlesDream.Frontend/App.fs +++ b/src/SomhairlesDream.Frontend/App.fs @@ -33,7 +33,7 @@ type RunSelection = type ViewState = { Renderer: ThreeRenderer Scene: ThreeNode - Camera: ThreeNode + Camera: ThreeCamera Group: ThreeNode Canvas: HTMLCanvasElement } @@ -46,6 +46,7 @@ let mutable eventSource: EventSource option = None let mutable currentProjectId = "" let mutable currentRunId = "" let mutable loadedStepPath: string option = None +let mutable displayedNode: ThreeNode option = None let mutable freezeRotation = false let element id = document.getElementById(id) @@ -112,6 +113,7 @@ let addMesh (view: ViewState) (frame: ReplayFrame) = let renderFrame (frame: ReplayFrame) = selectedFrame <- frame + displayedNode <- None match viewState with | None -> () @@ -120,11 +122,49 @@ let renderFrame (frame: ReplayFrame) = addMesh view frame view.Renderer.render(view.Scene, view.Camera) +let frameObject (view: ViewState) (node: ThreeNode) = + let box = createBox3 () + box.setFromObject node |> ignore + + if not (box.isEmpty ()) then + let center = createVector3 () + let size = createVector3 () + box.getCenter center |> ignore + box.getSize size |> ignore + let radius = 0.5 * size.length () + + if radius > 0.000001 then + node.position.set(-center.x, -center.y, -center.z) |> ignore + let camera = view.Camera + let width = max 1.0 (float view.Canvas.clientWidth) + let height = max 1.0 (float view.Canvas.clientHeight) + let aspect = width / height + camera.aspect <- aspect + let fovY = camera.fov * Math.PI / 180.0 + let fovX = 2.0 * atan (tan (fovY / 2.0) * aspect) + let limitingFov = min fovY fovX + let distance = 1.28 * radius / sin (limitingFov / 2.0) + let dirX, dirY, dirZ = 0.62, 0.45, 0.70 + let norm = sqrt (dirX * dirX + dirY * dirY + dirZ * dirZ) + camera.position.set(dirX / norm * distance, dirY / norm * distance, dirZ / norm * distance) + |> ignore + camera.near <- max (distance * 0.01) (distance - radius * 2.5) + camera.far <- distance + radius * 4.0 + camera.lookAt(0.0, 0.0, 0.0) + camera.updateProjectionMatrix () + let resizeView (view: ViewState) = let width = max 1.0 (float view.Canvas.clientWidth) let height = max 1.0 (float view.Canvas.clientHeight) view.Renderer.setSize(width, height, false) - renderFrame selectedFrame + view.Camera.aspect <- width / height + view.Camera.updateProjectionMatrix() + + match displayedNode with + | Some node -> + frameObject view node + view.Renderer.render(view.Scene, view.Camera) + | None -> renderFrame selectedFrame let initializeView () = let canvas = document.getElementById("viewport-canvas") :?> HTMLCanvasElement @@ -161,6 +201,7 @@ let initializeView () = viewState <- Some view freezeRotation <- (queryParam "freeze") = "1" + exposeViewerDebug camera scene group canvas renderFrame selectedFrame window.addEventListener("resize", fun _ -> resizeView view) @@ -204,6 +245,9 @@ let loadStepArtifact (view: ViewState) (stepPath: string) = (fun gltf -> view.Group.clear() view.Group.add gltf.scene + displayedNode <- Some gltf.scene + frameObject view gltf.scene + view.Renderer.render(view.Scene, view.Camera) setText "viewport-note" (sprintf "管线几何 · %s" stepPath)) (fun _ -> setText "viewport-note" (sprintf "管线几何加载失败 · %s" stepPath)) diff --git a/src/SomhairlesDream.Frontend/Bindings.fs b/src/SomhairlesDream.Frontend/Bindings.fs index 9dbfa00..a0276cc 100644 --- a/src/SomhairlesDream.Frontend/Bindings.fs +++ b/src/SomhairlesDream.Frontend/Bindings.fs @@ -8,6 +8,7 @@ type ThreeVector = abstract x: float with get, set abstract y: float with get, set abstract z: float with get, set + abstract length: unit -> float [<Erase>] type ThreeNode = @@ -19,6 +20,22 @@ type ThreeNode = abstract background: obj with get, set [<Erase>] +type ThreeBox3 = + abstract setFromObject: node: ThreeNode -> ThreeBox3 + abstract isEmpty: unit -> bool + abstract getCenter: target: ThreeVector -> ThreeVector + abstract getSize: target: ThreeVector -> ThreeVector + +[<Erase>] +type ThreeCamera = + inherit ThreeNode + abstract fov: float with get, set + abstract aspect: float with get, set + abstract near: float with get, set + abstract far: float with get, set + abstract updateProjectionMatrix: unit -> unit + +[<Erase>] type ThreeRenderer = abstract setPixelRatio: ratio: float -> unit abstract setSize: width: float * height: float * updateStyle: bool -> unit @@ -71,7 +88,16 @@ let createScene () : ThreeNode = jsNative let createGroup () : ThreeNode = jsNative [<Emit("new THREE.PerspectiveCamera($0, $1, $2, $3)")>] -let createPerspectiveCamera (fieldOfView: float, aspect: float, nearPlane: float, farPlane: float) : ThreeNode = jsNative +let createPerspectiveCamera (fieldOfView: float, aspect: float, nearPlane: float, farPlane: float) : ThreeCamera = jsNative + +[<Emit("new THREE.Box3()")>] +let createBox3 () : ThreeBox3 = jsNative + +[<Emit("new THREE.Vector3()")>] +let createVector3 () : ThreeVector = jsNative + +[<Emit("window.SOMHAIRLES_VIEWER = { camera: $0, scene: $1, group: $2, canvas: $3 }")>] +let exposeViewerDebug (camera: ThreeCamera) (scene: ThreeNode) (group: ThreeNode) (canvas: obj) : unit = jsNative [<Emit("new THREE.WebGLRenderer($0)")>] let createRenderer (options: obj) : ThreeRenderer = jsNative 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()) |
