qa-pages.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. #!/usr/bin/env python3
  2. """Headless QA for the three A5 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. Works over the browser endpoint (Target.createTarget + flatten sessions),
  8. which sidesteps the flaky /json/new page-target websocket on this Chromium.
  9. """
  10. import json
  11. import subprocess
  12. import sys
  13. import time
  14. import urllib.request
  15. import websocket
  16. PORT = 9333
  17. PAGES = [
  18. ("locksmith", "developer/locksmith/index.html"),
  19. ("vallama", "developer/vallama/index.html"),
  20. ("usm", "developer/usm/index.html"),
  21. ]
  22. WIDTHS = [390, 768, 1280]
  23. SCHEMES = ["light", "dark"]
  24. def http_put(url):
  25. req = urllib.request.Request(url, method="PUT", data=b"")
  26. return json.load(urllib.request.urlopen(req))
  27. class Cdp:
  28. def __init__(self, ws):
  29. self.ws = ws
  30. self.mid = 0
  31. def call(self, method, params=None, timeout=30):
  32. self.mid += 1
  33. mid = self.mid
  34. self.ws.send(json.dumps({"id": mid, "method": method,
  35. "params": params or {}}))
  36. deadline = time.time() + timeout
  37. while time.time() < deadline:
  38. try:
  39. msg = json.loads(self.ws.recv())
  40. except websocket.WebSocketTimeoutException:
  41. continue
  42. if msg.get("id") == mid:
  43. if "error" in msg:
  44. raise RuntimeError(f"{method}: {msg['error']}")
  45. return msg.get("result", {})
  46. raise TimeoutError(method)
  47. def eval(self, expr):
  48. r = self.call("Runtime.evaluate",
  49. {"expression": expr, "returnByValue": True})
  50. return r.get("result", {}).get("value")
  51. def main():
  52. proc = subprocess.Popen([
  53. "chromium-browser", "--headless=new", "--disable-gpu", "--no-sandbox",
  54. "--remote-allow-origins=*", f"--remote-debugging-port={PORT}",
  55. "about:blank",
  56. ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  57. try:
  58. ver = None
  59. for _ in range(50):
  60. try:
  61. ver = json.load(urllib.request.urlopen(
  62. f"http://127.0.0.1:{PORT}/json/version"))
  63. break
  64. except Exception:
  65. time.sleep(0.2)
  66. assert ver, "chromium did not start"
  67. ws = websocket.create_connection(
  68. ver["webSocketDebuggerUrl"],
  69. origin=f"http://127.0.0.1:{PORT}", timeout=5)
  70. ws.settimeout(0.5)
  71. cdp = Cdp(ws)
  72. failures = []
  73. bg_by_page = {}
  74. for name, path in PAGES:
  75. url = "file://" + sys.argv[1].rstrip("/") + "/" + path
  76. tid = cdp.call("Target.createTarget", {"url": "about:blank"})["targetId"]
  77. sess = cdp.call("Target.attachToTarget",
  78. {"targetId": tid, "flatten": True})["sessionId"]
  79. def send(method, params=None):
  80. cdp.mid += 1
  81. mid = cdp.mid
  82. ws.send(json.dumps({"id": mid, "method": method,
  83. "params": params or {},
  84. "sessionId": sess}))
  85. deadline = time.time() + 30
  86. while time.time() < deadline:
  87. try:
  88. msg = json.loads(ws.recv())
  89. except websocket.WebSocketTimeoutException:
  90. continue
  91. if msg.get("id") == mid:
  92. if "error" in msg:
  93. raise RuntimeError(f"{method}: {msg['error']}")
  94. return msg.get("result", {})
  95. raise TimeoutError(method)
  96. send("Page.enable")
  97. send("Emulation.setDeviceMetricsOverride",
  98. {"width": 390, "height": 900, "deviceScaleFactor": 1,
  99. "mobile": True})
  100. send("Page.navigate", {"url": url})
  101. time.sleep(1.5) # fonts + layout settle
  102. for scheme in SCHEMES:
  103. send("Emulation.setEmulatedMedia",
  104. {"features": [{"name": "prefers-color-scheme",
  105. "value": scheme}]})
  106. for w in WIDTHS:
  107. send("Emulation.setDeviceMetricsOverride",
  108. {"width": w, "height": 900, "deviceScaleFactor": 1,
  109. "mobile": w < 500})
  110. time.sleep(0.3)
  111. data = json.loads(send("Runtime.evaluate", {
  112. "expression": """(() => {
  113. const de = document.documentElement;
  114. const over = [];
  115. const w = de.clientWidth;
  116. for (const el of document.querySelectorAll('body *')) {
  117. if (el.closest('pre')) continue;
  118. const b = el.getBoundingClientRect();
  119. if (b.width && (b.right > w + 1 || b.left < -1)) {
  120. over.push(el.tagName + '.' + (el.className||''));
  121. }
  122. }
  123. return JSON.stringify({
  124. scrollW: de.scrollWidth, clientW: w,
  125. bg: getComputedStyle(document.body).backgroundColor,
  126. over: over.slice(0, 5),
  127. imgsBroken: [...document.images]
  128. .filter(i => !i.complete || i.naturalWidth === 0)
  129. .map(i => i.getAttribute('src')),
  130. });
  131. })()""", "returnByValue": True})
  132. ["result"]["value"])
  133. label = f"{name}/{scheme}/{w}"
  134. problems = []
  135. if data["scrollW"] > data["clientW"] + 1:
  136. problems.append(
  137. f"scroll {data['scrollW']}>{data['clientW']}")
  138. if data["over"]:
  139. problems.append("overflow: " + ",".join(data["over"]))
  140. if data["imgsBroken"]:
  141. problems.append(
  142. "broken imgs: " + ",".join(data["imgsBroken"]))
  143. if problems:
  144. failures.append(label + ": " + "; ".join(problems))
  145. print(f"FAIL {label}: {'; '.join(problems)}",
  146. file=sys.stderr)
  147. else:
  148. print(f"ok {label} bg={data['bg']} "
  149. f"{data['scrollW']}/{data['clientW']}")
  150. bg_by_page.setdefault((name, w), {})[scheme] = data["bg"]
  151. cdp.call("Target.closeTarget", {"targetId": tid})
  152. # scheme sanity: bg must differ between light and dark
  153. for (name, w), bgs in sorted(bg_by_page.items()):
  154. if bgs.get("light") == bgs.get("dark"):
  155. failures.append(f"{name}@{w}: light/dark bg identical "
  156. f"({bgs.get('light')}) — scheme flip failed")
  157. if failures:
  158. print(f"\n{len(failures)} failure(s)", file=sys.stderr)
  159. for f in failures:
  160. print(" -", f, file=sys.stderr)
  161. return 1
  162. print("\nALL OK (light/dark differ, no overflow, no broken images)")
  163. return 0
  164. finally:
  165. proc.terminate()
  166. if __name__ == "__main__":
  167. sys.exit(main())