| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- #!/usr/bin/env python3
- """Headless QA for all site pages: light/dark render + overflow at 390/768/1280.
- CDP per discovery #82: --force-prefers-color-scheme is silently ignored, so
- Emulation.setEmulatedMedia is the only reliable scheme flip. Metrics are set
- BEFORE navigate so layout never happens at another width; uncontained <code>
- inside <pre> is filtered when hunting real page-level overflow.
- Doc pages also get a sticky-ToC assertion: in the two-column docs layout
- (wide viewport) the ToC must be position:sticky and sit beside the prose
- column; in the single-column layout (narrow viewport) it must be static.
- Works over the browser endpoint (Target.createTarget + flatten sessions),
- which sidesteps the flaky /json/new page-target websocket on this Chromium.
- """
- import json
- import subprocess
- import sys
- import time
- import urllib.request
- import websocket
- PORT = 9333
- PAGES = [
- ("home", "index.html"),
- ("developer", "developer/index.html"),
- ("invercargill", "developer/invercargill/index.html"),
- ("invercargill-json", "developer/invercargill-json/index.html"),
- ("inversion", "developer/inversion/index.html"),
- ("invercargill-sql", "developer/invercargill-sql/index.html"),
- ("invercargill-sql-inversion", "developer/invercargill-sql-inversion/index.html"),
- ("astralis", "developer/astralis/index.html"),
- ("statum", "developer/statum/index.html"),
- ("spry", "developer/spry/index.html"),
- ("locksmith", "developer/locksmith/index.html"),
- ("vallama", "developer/vallama/index.html"),
- ("usm", "developer/usm/index.html"),
- ]
- WIDTHS = [390, 768, 1280]
- SCHEMES = ["light", "dark"]
- def http_put(url):
- req = urllib.request.Request(url, method="PUT", data=b"")
- return json.load(urllib.request.urlopen(req))
- class Cdp:
- def __init__(self, ws):
- self.ws = ws
- self.mid = 0
- def call(self, method, params=None, timeout=30):
- self.mid += 1
- mid = self.mid
- self.ws.send(json.dumps({"id": mid, "method": method,
- "params": params or {}}))
- deadline = time.time() + timeout
- while time.time() < deadline:
- try:
- msg = json.loads(self.ws.recv())
- except websocket.WebSocketTimeoutException:
- continue
- if msg.get("id") == mid:
- if "error" in msg:
- raise RuntimeError(f"{method}: {msg['error']}")
- return msg.get("result", {})
- raise TimeoutError(method)
- def eval(self, expr):
- r = self.call("Runtime.evaluate",
- {"expression": expr, "returnByValue": True})
- return r.get("result", {}).get("value")
- def main():
- proc = subprocess.Popen([
- "chromium-browser", "--headless=new", "--disable-gpu", "--no-sandbox",
- "--remote-allow-origins=*", f"--remote-debugging-port={PORT}",
- "about:blank",
- ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- try:
- ver = None
- for _ in range(50):
- try:
- ver = json.load(urllib.request.urlopen(
- f"http://127.0.0.1:{PORT}/json/version"))
- break
- except Exception:
- time.sleep(0.2)
- assert ver, "chromium did not start"
- ws = websocket.create_connection(
- ver["webSocketDebuggerUrl"],
- origin=f"http://127.0.0.1:{PORT}", timeout=5)
- ws.settimeout(0.5)
- cdp = Cdp(ws)
- failures = []
- bg_by_page = {}
- for name, path in PAGES:
- url = "file://" + sys.argv[1].rstrip("/") + "/" + path
- tid = cdp.call("Target.createTarget", {"url": "about:blank"})["targetId"]
- sess = cdp.call("Target.attachToTarget",
- {"targetId": tid, "flatten": True})["sessionId"]
- def send(method, params=None):
- cdp.mid += 1
- mid = cdp.mid
- ws.send(json.dumps({"id": mid, "method": method,
- "params": params or {},
- "sessionId": sess}))
- deadline = time.time() + 30
- while time.time() < deadline:
- try:
- msg = json.loads(ws.recv())
- except websocket.WebSocketTimeoutException:
- continue
- if msg.get("id") == mid:
- if "error" in msg:
- raise RuntimeError(f"{method}: {msg['error']}")
- return msg.get("result", {})
- raise TimeoutError(method)
- send("Page.enable")
- send("Emulation.setDeviceMetricsOverride",
- {"width": 390, "height": 900, "deviceScaleFactor": 1,
- "mobile": True})
- send("Page.navigate", {"url": url})
- time.sleep(1.5) # fonts + layout settle
- for scheme in SCHEMES:
- send("Emulation.setEmulatedMedia",
- {"features": [{"name": "prefers-color-scheme",
- "value": scheme}]})
- for w in WIDTHS:
- send("Emulation.setDeviceMetricsOverride",
- {"width": w, "height": 900, "deviceScaleFactor": 1,
- "mobile": w < 500})
- time.sleep(0.3)
- data = json.loads(send("Runtime.evaluate", {
- "expression": """(() => {
- const de = document.documentElement;
- const over = [];
- const w = de.clientWidth;
- for (const el of document.querySelectorAll('body *')) {
- if (el.closest('pre')) continue;
- const b = el.getBoundingClientRect();
- if (b.width && (b.right > w + 1 || b.left < -1)) {
- over.push(el.tagName + '.' + (el.className||''));
- }
- }
- const toc = document.querySelector('.docs-toc');
- let tocInfo = null;
- if (toc) {
- const cs = getComputedStyle(toc);
- const layout = getComputedStyle(
- document.querySelector('.docs-layout'));
- const prose = document.querySelector('.docs-prose');
- tocInfo = {
- pos: cs.position,
- twoCol: layout.gridTemplateColumns.split(' ').length === 2,
- besideProse: prose ? toc.getBoundingClientRect().left
- >= prose.getBoundingClientRect().right - 1 : null,
- };
- }
- return JSON.stringify({
- scrollW: de.scrollWidth, clientW: w,
- bg: getComputedStyle(document.body).backgroundColor,
- over: over.slice(0, 5),
- imgsBroken: [...document.images]
- .filter(i => !i.complete || i.naturalWidth === 0)
- .map(i => i.getAttribute('src')),
- toc: tocInfo,
- });
- })()""", "returnByValue": True})
- ["result"]["value"])
- label = f"{name}/{scheme}/{w}"
- problems = []
- if data["scrollW"] > data["clientW"] + 1:
- problems.append(
- f"scroll {data['scrollW']}>{data['clientW']}")
- if data["over"]:
- problems.append("overflow: " + ",".join(data["over"]))
- if data["imgsBroken"]:
- problems.append(
- "broken imgs: " + ",".join(data["imgsBroken"]))
- toc = data.get("toc")
- if toc:
- # sticky ToC expectation: two-column layout at wide
- # widths -> sticky + right of prose; one-column at
- # narrow widths -> static
- two_col_layout = toc["twoCol"]
- if two_col_layout and w >= 1000:
- if toc["pos"] != "sticky":
- problems.append(
- f"toc pos={toc['pos']} in 2-col @ {w}")
- if toc["besideProse"] is False:
- problems.append(
- "toc not beside prose column")
- if not two_col_layout and toc["pos"] != "static":
- problems.append(
- f"toc pos={toc['pos']} in 1-col @ {w}")
- if problems:
- failures.append(label + ": " + "; ".join(problems))
- print(f"FAIL {label}: {'; '.join(problems)}",
- file=sys.stderr)
- else:
- tocst = ""
- if data.get("toc"):
- tocst = (f" toc={data['toc']['pos']}"
- f"{'/2col' if data['toc']['twoCol'] else '/1col'}")
- print(f"ok {label} bg={data['bg']} "
- f"{data['scrollW']}/{data['clientW']}{tocst}")
- bg_by_page.setdefault((name, w), {})[scheme] = data["bg"]
- cdp.call("Target.closeTarget", {"targetId": tid})
- # scheme sanity: bg must differ between light and dark
- for (name, w), bgs in sorted(bg_by_page.items()):
- if bgs.get("light") == bgs.get("dark"):
- failures.append(f"{name}@{w}: light/dark bg identical "
- f"({bgs.get('light')}) — scheme flip failed")
- if failures:
- print(f"\n{len(failures)} failure(s)", file=sys.stderr)
- for f in failures:
- print(" -", f, file=sys.stderr)
- return 1
- print("\nALL OK (light/dark differ, no overflow, no broken images)")
- return 0
- finally:
- proc.terminate()
- if __name__ == "__main__":
- sys.exit(main())
|