summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--README.md63
-rw-r--r--public/App.js44
-rw-r--r--src/SomhairlesDream.Frontend/App.fs48
-rw-r--r--src/SomhairlesDream.Frontend/Bindings.fs28
-rwxr-xr-xtools/acceptance/e2e_acceptance.py351
6 files changed, 528 insertions, 8 deletions
diff --git a/.gitignore b/.gitignore
index 6b209f4..bff40aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,5 @@ dist/
__pycache__/
*.pyc
.venv/
+tools/acceptance/deps/
+scratch/
diff --git a/README.md b/README.md
index ae59ca2..907fc50 100644
--- a/README.md
+++ b/README.md
@@ -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())