qa-pages.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. #!/usr/bin/env python3
  2. """Headless QA for all site pages: light/dark render + overflow at 390/768/1280.
  3. CDP per discovery #82: --force-prefers-color-scheme is silently ignored, so
  4. Emulation.setEmulatedMedia is the only reliable scheme flip. Metrics are set
  5. BEFORE navigate so layout never happens at another width; uncontained <code>
  6. inside <pre> is filtered when hunting real page-level overflow.
  7. Doc pages also get a sticky-ToC assertion: in the two-column docs layout
  8. (wide viewport) the ToC must be position:sticky and sit beside the prose
  9. column; in the single-column layout (narrow viewport) it must be static.
  10. Works over the browser endpoint (Target.createTarget + flatten sessions),
  11. which sidesteps the flaky /json/new page-target websocket on this Chromium.
  12. """
  13. import json
  14. import subprocess
  15. import sys
  16. import time
  17. import urllib.request
  18. import websocket
  19. PORT = 9333
  20. PAGES = [
  21. ("home", "index.html"),
  22. ("developer", "developer/index.html"),
  23. ("invercargill", "developer/invercargill/index.html"),
  24. ("invercargill-json", "developer/invercargill-json/index.html"),
  25. ("inversion", "developer/inversion/index.html"),
  26. ("invercargill-sql", "developer/invercargill-sql/index.html"),
  27. ("invercargill-sql-inversion", "developer/invercargill-sql-inversion/index.html"),
  28. ("astralis", "developer/astralis/index.html"),
  29. ("statum", "developer/statum/index.html"),
  30. ("spry", "developer/spry/index.html"),
  31. ("locksmith", "developer/locksmith/index.html"),
  32. ("vallama", "developer/vallama/index.html"),
  33. ("usm", "developer/usm/index.html"),
  34. ]
  35. WIDTHS = [390, 768, 1280]
  36. SCHEMES = ["light", "dark"]
  37. def http_put(url):
  38. req = urllib.request.Request(url, method="PUT", data=b"")
  39. return json.load(urllib.request.urlopen(req))
  40. class Cdp:
  41. def __init__(self, ws):
  42. self.ws = ws
  43. self.mid = 0
  44. def call(self, method, params=None, timeout=30):
  45. self.mid += 1
  46. mid = self.mid
  47. self.ws.send(json.dumps({"id": mid, "method": method,
  48. "params": params or {}}))
  49. deadline = time.time() + timeout
  50. while time.time() < deadline:
  51. try:
  52. msg = json.loads(self.ws.recv())
  53. except websocket.WebSocketTimeoutException:
  54. continue
  55. if msg.get("id") == mid:
  56. if "error" in msg:
  57. raise RuntimeError(f"{method}: {msg['error']}")
  58. return msg.get("result", {})
  59. raise TimeoutError(method)
  60. def eval(self, expr):
  61. r = self.call("Runtime.evaluate",
  62. {"expression": expr, "returnByValue": True})
  63. return r.get("result", {}).get("value")
  64. def main():
  65. proc = subprocess.Popen([
  66. "chromium-browser", "--headless=new", "--disable-gpu", "--no-sandbox",
  67. "--remote-allow-origins=*", f"--remote-debugging-port={PORT}",
  68. "about:blank",
  69. ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  70. try:
  71. ver = None
  72. for _ in range(50):
  73. try:
  74. ver = json.load(urllib.request.urlopen(
  75. f"http://127.0.0.1:{PORT}/json/version"))
  76. break
  77. except Exception:
  78. time.sleep(0.2)
  79. assert ver, "chromium did not start"
  80. ws = websocket.create_connection(
  81. ver["webSocketDebuggerUrl"],
  82. origin=f"http://127.0.0.1:{PORT}", timeout=5)
  83. ws.settimeout(0.5)
  84. cdp = Cdp(ws)
  85. failures = []
  86. bg_by_page = {}
  87. for name, path in PAGES:
  88. url = "file://" + sys.argv[1].rstrip("/") + "/" + path
  89. tid = cdp.call("Target.createTarget", {"url": "about:blank"})["targetId"]
  90. sess = cdp.call("Target.attachToTarget",
  91. {"targetId": tid, "flatten": True})["sessionId"]
  92. def send(method, params=None):
  93. cdp.mid += 1
  94. mid = cdp.mid
  95. ws.send(json.dumps({"id": mid, "method": method,
  96. "params": params or {},
  97. "sessionId": sess}))
  98. deadline = time.time() + 30
  99. while time.time() < deadline:
  100. try:
  101. msg = json.loads(ws.recv())
  102. except websocket.WebSocketTimeoutException:
  103. continue
  104. if msg.get("id") == mid:
  105. if "error" in msg:
  106. raise RuntimeError(f"{method}: {msg['error']}")
  107. return msg.get("result", {})
  108. raise TimeoutError(method)
  109. send("Page.enable")
  110. send("Emulation.setDeviceMetricsOverride",
  111. {"width": 390, "height": 900, "deviceScaleFactor": 1,
  112. "mobile": True})
  113. send("Page.navigate", {"url": url})
  114. time.sleep(1.5) # fonts + layout settle
  115. for scheme in SCHEMES:
  116. send("Emulation.setEmulatedMedia",
  117. {"features": [{"name": "prefers-color-scheme",
  118. "value": scheme}]})
  119. for w in WIDTHS:
  120. send("Emulation.setDeviceMetricsOverride",
  121. {"width": w, "height": 900, "deviceScaleFactor": 1,
  122. "mobile": w < 500})
  123. time.sleep(0.3)
  124. data = json.loads(send("Runtime.evaluate", {
  125. "expression": """(() => {
  126. const de = document.documentElement;
  127. const over = [];
  128. const w = de.clientWidth;
  129. for (const el of document.querySelectorAll('body *')) {
  130. if (el.closest('pre')) continue;
  131. const b = el.getBoundingClientRect();
  132. if (b.width && (b.right > w + 1 || b.left < -1)) {
  133. over.push(el.tagName + '.' + (el.className||''));
  134. }
  135. }
  136. const toc = document.querySelector('.docs-toc');
  137. let tocInfo = null;
  138. if (toc) {
  139. const cs = getComputedStyle(toc);
  140. const layout = getComputedStyle(
  141. document.querySelector('.docs-layout'));
  142. const prose = document.querySelector('.docs-prose');
  143. tocInfo = {
  144. pos: cs.position,
  145. twoCol: layout.gridTemplateColumns.split(' ').length === 2,
  146. besideProse: prose ? toc.getBoundingClientRect().left
  147. >= prose.getBoundingClientRect().right - 1 : null,
  148. };
  149. }
  150. return JSON.stringify({
  151. scrollW: de.scrollWidth, clientW: w,
  152. bg: getComputedStyle(document.body).backgroundColor,
  153. over: over.slice(0, 5),
  154. imgsBroken: [...document.images]
  155. .filter(i => !i.complete || i.naturalWidth === 0)
  156. .map(i => i.getAttribute('src')),
  157. toc: tocInfo,
  158. });
  159. })()""", "returnByValue": True})
  160. ["result"]["value"])
  161. label = f"{name}/{scheme}/{w}"
  162. problems = []
  163. if data["scrollW"] > data["clientW"] + 1:
  164. problems.append(
  165. f"scroll {data['scrollW']}>{data['clientW']}")
  166. if data["over"]:
  167. problems.append("overflow: " + ",".join(data["over"]))
  168. if data["imgsBroken"]:
  169. problems.append(
  170. "broken imgs: " + ",".join(data["imgsBroken"]))
  171. toc = data.get("toc")
  172. if toc:
  173. # sticky ToC expectation: two-column layout at wide
  174. # widths -> sticky + right of prose; one-column at
  175. # narrow widths -> static
  176. two_col_layout = toc["twoCol"]
  177. if two_col_layout and w >= 1000:
  178. if toc["pos"] != "sticky":
  179. problems.append(
  180. f"toc pos={toc['pos']} in 2-col @ {w}")
  181. if toc["besideProse"] is False:
  182. problems.append(
  183. "toc not beside prose column")
  184. if not two_col_layout and toc["pos"] != "static":
  185. problems.append(
  186. f"toc pos={toc['pos']} in 1-col @ {w}")
  187. if problems:
  188. failures.append(label + ": " + "; ".join(problems))
  189. print(f"FAIL {label}: {'; '.join(problems)}",
  190. file=sys.stderr)
  191. else:
  192. tocst = ""
  193. if data.get("toc"):
  194. tocst = (f" toc={data['toc']['pos']}"
  195. f"{'/2col' if data['toc']['twoCol'] else '/1col'}")
  196. print(f"ok {label} bg={data['bg']} "
  197. f"{data['scrollW']}/{data['clientW']}{tocst}")
  198. bg_by_page.setdefault((name, w), {})[scheme] = data["bg"]
  199. cdp.call("Target.closeTarget", {"targetId": tid})
  200. # scheme sanity: bg must differ between light and dark
  201. for (name, w), bgs in sorted(bg_by_page.items()):
  202. if bgs.get("light") == bgs.get("dark"):
  203. failures.append(f"{name}@{w}: light/dark bg identical "
  204. f"({bgs.get('light')}) — scheme flip failed")
  205. if failures:
  206. print(f"\n{len(failures)} failure(s)", file=sys.stderr)
  207. for f in failures:
  208. print(" -", f, file=sys.stderr)
  209. return 1
  210. print("\nALL OK (light/dark differ, no overflow, no broken images)")
  211. return 0
  212. finally:
  213. proc.terminate()
  214. if __name__ == "__main__":
  215. sys.exit(main())