diff --git a/README.md b/README.md index 00b6650..764edff 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,26 @@ en/es parity gate (`npm run pretest`): cd frontend && npm test ``` +**Browser smoke** (`scripts/smoke.py`) — renders every route in a real +Chrome and checks four things jsdom structurally cannot see: + +```bash +chrome --headless --remote-debugging-port=9222 & +backend/venv/bin/python scripts/smoke.py # 390x844, live site +backend/venv/bin/python scripts/smoke.py --width 1440 # desktop +``` + +Run it after any UI change and before any deploy; it exits non-zero on +failure. **This is not optional belt-and-braces.** Both suites were fully +green while `new THREE.WebGLRenderer()` threw on any 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 could not see it because jsdom never builds a WebGL context. This +script catches that, plus horizontal scroll on a phone, inputs under 16px +(which make iOS zoom in and never back out), and any uncaught error during +load. All four are proven to fire, by pointing it at a page that has each +defect on purpose. + **Hardware-in-the-loop** (cannot be unit tested): the RTL-SDR sweep, mic permissions, phone sensors, and everything in `firmware/` need real hardware passes. `docs/CHANNELS.md` tracks exactly what has and hasn't had diff --git a/scripts/smoke.py b/scripts/smoke.py new file mode 100755 index 0000000..ada22ab --- /dev/null +++ b/scripts/smoke.py @@ -0,0 +1,173 @@ +#!/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()))