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>
174 lines
7.2 KiB
Python
Executable File
174 lines
7.2 KiB
Python
Executable File
#!/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()))
|