test: check the app in a real browser, because the suites cannot

Every serious defect this session survived a green suite. The sharpest:
THREE.WebGLRenderer throws on a device with no WebGL, the exception escaped
its effect, React unmounted the whole root, and every page rendered blank.
432 frontend tests passed throughout, because jsdom never builds a WebGL
context and nothing exercised a real page in a real browser.

scripts/smoke.py closes that blind spot. It drives Chrome over the DevTools
Protocol — no framework, no npm dependency — and checks each of the 13
routes for the four failures that have actually happened here: a page that
renders nothing, horizontal scroll on a phone, an input under 16px (iOS
zooms on focus and never zooms back), and any uncaught error during load.
Non-zero exit, so it can gate a deploy.

All four checks are PROVEN to fire, by pointing the script at a page built
with each defect on purpose — which is how I found that my own
horizontal-scroll check was broken. It compared scrollWidth against
window.innerWidth, but under Chrome's mobile emulation innerWidth expands to
the CONTENT width when a page overflows: 1200 on a 390px viewport, so the
difference was always 0 and the check could never fire. It reported "ok" on
a deliberately 1200px-wide page. Now compares against
documentElement.clientWidth, which stays at the true 390, and reports
"H-SCROLL +810px" on that same page.

Worth stating plainly: the earlier clean sweep I reported was measured with
that broken check, so its horizontal-scroll result was meaningless. Re-run
with the fix, all 13 routes still pass at 390x844 and 1440x900 — but that is
the run that counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-08-07 00:57:37 +00:00
parent b2b0dc319a
commit e02aecee65
2 changed files with 193 additions and 0 deletions

View File

@@ -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

173
scripts/smoke.py Executable file
View File

@@ -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()))