facett-wasm-demo/tests/verify_webgpu_render.py
"""Headless WEBGPU render guard for a `facett-wasm-demo` bundle.
Serves a `dist/` on loopback, loads it in headless chromium with the WebGPU flags,
and asserts the harness actually rendered on WebGPU:
* the per-frame state hook returns real `state_json` — the panel ran, and this is
what it rendered, not a screenshot and not a substring;
* the canvas's REAL graphics API is `webgpu` (asked of the canvas itself, so a
build that quietly regrew a WebGL path cannot pass);
* `window.__facett_backend` is `webgpu` — the running bundle's own report;
* `window.__facett_gpu_error` is empty — no typed `facet-gpu-1` refusal fired;
* the page raised no JS errors.
Why this exists: every `<repo>-demo` in the constellation (nornir / holger / dwarves)
mounts on this harness, so the backend IT picks is the backend they all inherit. The
harness had no permanent render guard, which is how it sat on glow/WebGL while the
rest of facett moved to WebGPU. Source-level tests cannot catch that — only loading
the shipped bundle in a real browser can.
Build the fixture first: cargo xtask panel-wasm
Then: python tests/verify_webgpu_render.py <dist-dir> [label]
## The flags are load-bearing
`--use-gl=swiftshader` returns a NULL ADAPTER and nothing mounts. The working set is
the one below: ANGLE and Vulkan both on SwiftShader, which is a CPU implementation —
no GPU and no display are needed, so this runs anywhere.
## Serve over http://127.0.0.1, never file:// or about:blank
`navigator.gpu` is SECURE-CONTEXT gated. Probing a bundle on `about:blank` reports
"no WebGPU" on a browser that has it — a false negative that reads exactly like a
real failure. Loopback counts as secure, which is why this script brings up a server
instead of opening the file.
"""
from __future__ import annotations
import http.server
import json
import socketserver
import sys
import threading
from playwright.sync_api import sync_playwright
DIST = sys.argv[1] if len(sys.argv) > 1 else "dist"
LABEL = sys.argv[2] if len(sys.argv) > 2 else "facett-wasm-demo"
# The canvas id + state hook `examples/panel.rs` exports, and the globals
# `WebDemo::mount` publishes (`BACKEND_GLOBAL` / `ERROR_GLOBAL`).
CANVAS_ID = "demo_canvas"
STATE_HOOK = "__demo_state"
BACKEND_GLOBAL = "__facett_backend"
ERROR_GLOBAL = "__facett_gpu_error"
WEBGPU_FLAGS = [
"--enable-unsafe-webgpu",
"--enable-features=Vulkan",
"--use-angle=swiftshader",
"--use-vulkan=swiftshader",
"--enable-unsafe-swiftshader",
]
class H(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=DIST, **kw)
def log_message(self, *a):
pass
httpd = socketserver.TCPServer(("127.0.0.1", 0), H)
PORT = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
URL = f"http://127.0.0.1:{PORT}/index.html"
# Ask the CANVAS what it is, rather than trusting the bundle's own claim. A canvas
# hands back the context it was created with; `getContext('webgl2')` on a canvas
# already bound to WebGPU returns null, so this cannot report both.
CANVAS_API = f"""() => {{
const c = document.getElementById('{CANVAS_ID}');
if (!c) return 'no-canvas';
try {{ if (c.getContext('webgpu')) return 'webgpu'; }} catch (_) {{}}
try {{ if (c.getContext('webgl2')) return 'webgl2'; }} catch (_) {{}}
try {{ if (c.getContext('webgl')) return 'webgl'; }} catch (_) {{}}
return 'none';
}}"""
rows = []
def row(name, ok, detail=""):
rows.append(ok)
print(f"{'PASS' if ok else 'FAIL'} {name:22} {detail}")
with sync_playwright() as p:
b = p.chromium.launch(args=WEBGPU_FLAGS)
pg = b.new_page(viewport={"width": 1280, "height": 800})
errors = []
pg.on("pageerror", lambda e: errors.append(str(e)))
pg.goto(URL, wait_until="load", timeout=90000)
# The harness has rendered when its per-frame state hook returns something real.
try:
pg.wait_for_function(
f"() => {{ try {{ const s = window.{STATE_HOOK} && window.{STATE_HOOK}();"
" return !!s && s !== 'null' && s.length > 2; } catch (e) { return false; } }",
timeout=90000)
state_ok = True
except Exception as e:
state_ok = False
print(" wait_for_state failed:", str(e)[:200])
raw = pg.evaluate(
f"() => {{ try {{ return window.{STATE_HOOK}(); }} catch(e) {{ return ''; }} }}")
api = pg.evaluate(CANVAS_API)
backend = pg.evaluate(f"() => window.{BACKEND_GLOBAL} || ''")
gpu_error = pg.evaluate(f"() => window.{ERROR_GLOBAL} || ''")
try:
parsed = json.loads(raw) if raw else None
except Exception:
parsed = None
row("state-hook-live", state_ok and bool(raw), f"{len(raw or '')} bytes of state_json")
row("state-json-parses", parsed is not None,
f"keys={sorted(parsed.keys())[:6]}" if isinstance(parsed, dict) else "")
row("canvas-api-webgpu", api == "webgpu", f"api={api!r}")
row("bundle-reports-webgpu", backend == "webgpu", f"{BACKEND_GLOBAL}={backend!r}")
row("no-gpu-refusal", not gpu_error, gpu_error[:160])
row("no-page-errors", not errors, "; ".join(errors[:2])[:160])
b.close()
httpd.shutdown()
ok = all(rows)
print(f"\n[{LABEL}] {sum(rows)}/{len(rows)} WebGPU render checks passed")
raise SystemExit(0 if ok else 1)