#!/usr/bin/env python3 """Render every route in a real browser and check it actually works. WHY THIS EXISTS --------------- The unit suites have been green through defects that made the app unusable. The sharpest example: `new THREE.WebGLRenderer()` throws on a device without WebGL, the exception escaped its effect, and React unmounted the entire root — every page a blank screen, no modes, no ask field, nothing. 432 frontend tests passed the whole time, because jsdom never builds a WebGL context and nothing exercised the real page in a real browser. The same blind spot covers the layout bugs this project keeps hitting: a panel that forces horizontal scroll on a phone, an input under 16px that makes iOS zoom in and never back out, a page that renders nothing because a component threw during mount. None of those are visible to a test that renders one component into jsdom. So this checks the four things that are cheap to verify, impossible to fake, and have each actually gone wrong here: * the page RENDERS — #root has children and the body has real text * no HORIZONTAL SCROLL — scrollWidth must not exceed the viewport * no INPUT below 16px — iOS zooms on focus below that and never zooms back * no UNCAUGHT JS ERROR during load It drives Chrome over the DevTools Protocol directly, so it needs no test framework and no npm dependency — just a Chrome running with `--remote-debugging-port`. USAGE ----- python3 scripts/smoke.py # phone width, live site python3 scripts/smoke.py --width 1440 # desktop width python3 scripts/smoke.py --base http://127.0.0.1:7777 Exits non-zero if any route fails, so it can gate a deploy. """ import argparse import asyncio import json import sys import urllib.request try: import websockets except ImportError: # pragma: no cover - environment guidance, not logic sys.exit("needs `websockets` (backend/venv has it: backend/venv/bin/python scripts/smoke.py)") # Every route in frontend/src/App.tsx that can be opened directly. Parameterised # detail routes are covered by their index pages; `*` is the catch-all. ROUTES = [ "/", "/enter", "/seance", "/log", "/codex", "/shop", "/inventory", "/devices", "/doctrine", "/profile", "/hunters", "/messages", "/secret", ] # How long to let a route settle before measuring. The séance opens a # websocket, waits on the veil/conditions fetch and mounts a 3D scene, so it # is materially slower than a static page — measuring too early reports a # blank screen that is really just an unfinished one. That exact mistake cost # a round of false alarms while writing this. SETTLE_SECONDS = 9 PROBE = """ JSON.stringify({ kids: document.getElementById('root')?.children.length ?? -1, text: (document.body.innerText || '').trim().length, scrollW: document.documentElement.scrollWidth, // documentElement.clientWidth, NOT window.innerWidth. Under Chrome's // mobile emulation innerWidth expands to the CONTENT width when a page // overflows (measured: 1200 on a 390px viewport), so comparing against it // yields 0 for every page and the check silently never fires. Caught by // pointing this script at a deliberately 1200px-wide page and watching it // report "ok" — clientWidth stays at the real 390. inner: document.documentElement.clientWidth, tiny: [...document.querySelectorAll('input,textarea')] .filter(e => parseFloat(getComputedStyle(e).fontSize) < 16) .map(e => e.className || e.type || 'input') }) """ def cdp(endpoint: str, path: str, method: str = "GET") -> dict: # Chrome 149 requires PUT for /json/new and answers GET with 405. req = urllib.request.Request(endpoint + path, method=method) body = urllib.request.urlopen(req, timeout=10).read() return json.loads(body) if body.strip().startswith(b"{") else {} async def check(endpoint: str, base: str, path: str, width: int, height: int) -> dict: target = cdp(endpoint, "/json/new?about:blank", method="PUT") errors: list[str] = [] counter = 0 try: async with websockets.connect(target["webSocketDebuggerUrl"], max_size=40_000_000) as ws: async def cmd(method: str, params: dict | None = None) -> dict: nonlocal counter counter += 1 await ws.send(json.dumps({"id": counter, "method": method, "params": params or {}})) while True: msg = json.loads(await ws.recv()) if msg.get("method") == "Runtime.exceptionThrown": errors.append(str(msg["params"]["exceptionDetails"].get("text", ""))[:160]) if msg.get("id") == counter: return msg.get("result", {}) await cmd("Emulation.setDeviceMetricsOverride", { "width": width, "height": height, "deviceScaleFactor": 2, "mobile": width < 800, }) await cmd("Runtime.enable") await cmd("Page.enable") await cmd("Page.navigate", {"url": base + path}) await asyncio.sleep(SETTLE_SECONDS) result = await cmd("Runtime.evaluate", {"expression": PROBE, "returnByValue": True}) data = json.loads(result["result"]["value"]) finally: cdp(endpoint, f"/json/close/{target['id']}") # A scrollWidth SMALLER than the viewport is normal (that is the # scrollbar); only an excess is a horizontal-scroll bug. overflow = max(0, data["scrollW"] - data["inner"]) problems = [] if data["kids"] <= 0 or data["text"] < 20: problems.append("BLANK") if overflow: problems.append(f"H-SCROLL +{overflow}px") if data["tiny"]: problems.append(f"INPUT<16px ({', '.join(data['tiny'][:3])})") if errors: problems.append(f"JS-ERROR {errors[0]}") return {"path": path, "overflow": overflow, "problems": problems, **data} async def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--base", default="https://spirit.thetempleofdoom.com") ap.add_argument("--endpoint", default="http://127.0.0.1:9222", help="Chrome DevTools endpoint") ap.add_argument("--width", type=int, default=390) ap.add_argument("--height", type=int, default=844) args = ap.parse_args() try: cdp(args.endpoint, "/json/version") except Exception as exc: sys.exit( f"no Chrome at {args.endpoint} ({exc}).\n" "start one with: chrome --headless --remote-debugging-port=9222" ) print(f"{args.base} @ {args.width}x{args.height}\n") print(f"{'route':<12} {'kids':>4} {'text':>6} status") failed = [] for path in ROUTES: r = await check(args.endpoint, args.base, path, args.width, args.height) status = "ok" if not r["problems"] else " · ".join(r["problems"]) print(f"{path:<12} {r['kids']:>4} {r['text']:>6} {status}") if r["problems"]: failed.append(r) if failed: print(f"\n{len(failed)} route(s) failed:") for r in failed: print(f" {r['path']}: {' · '.join(r['problems'])}") return 1 print(f"\nall {len(ROUTES)} routes render, no horizontal scroll, " "no sub-16px inputs, no uncaught errors") return 0 if __name__ == "__main__": raise SystemExit(asyncio.run(main()))