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:
20
README.md
20
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
|
||||
|
||||
173
scripts/smoke.py
Executable file
173
scripts/smoke.py
Executable 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()))
|
||||
Reference in New Issue
Block a user