mobile_audit.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  1. #!/usr/bin/env python3
  2. """The mobile/viewport layout audit (goal 41 T12).
  3. Boots the REAL app through the e2e harness (start_bundle: real binary,
  4. real statum wire, fake saddle daemon), seeds representative data for
  5. every page family, then renders each route in headless Chromium at
  6. three widths (360 = phone, 768 = tablet, 1280 = desktop) and reports
  7. every element that overflows its box horizontally, plus page-level
  8. horizontal scrolling.
  9. Rendering is FULL hydration, not static HTML: the audit seeds the
  10. harness login's auth frame into localStorage via the Chrome DevTools
  11. Protocol (Page.addScriptToEvaluateOnNewDocument) BEFORE statum.js runs,
  12. so every stm-* template evaluates against real server state, CSS
  13. applies, and client engines (sub-nav, log feed, filters) boot.
  14. Usage:
  15. python3 test/mobile_audit.py [app_binary] [--json OUT.json]
  16. [--widths 360,768,1280] [--routes a,b] [--keep]
  17. Exit code 0 = zero findings. chromium-browser/chromium is required; the
  18. script tells you when it is missing (the t12mobile bundle gates on the
  19. same probe and skips its browser part there).
  20. """
  21. import json
  22. import os
  23. import shutil
  24. import subprocess
  25. import sys
  26. import tempfile
  27. import time
  28. import urllib.request
  29. HERE = os.path.dirname(os.path.abspath(__file__))
  30. sys.path.insert(0, HERE)
  31. import harness # noqa: E402 (module-level import: sets REPO/APP defaults)
  32. # The harness's default scratch dir is a top-level /tmp path the
  33. # janitor reaps mid-run (a known fleet gotcha) — default to a
  34. # repo-local one unless the caller set their own.
  35. if "WEBSTACK_WORK" not in os.environ:
  36. os.environ["WEBSTACK_WORK"] = os.path.join(
  37. harness.REPO, "build", "ws-mobile")
  38. CHROME_CANDIDATES = ("chromium-browser", "chromium", "google-chrome",
  39. "google-chrome-stable")
  40. # Every route the app ships, with the fixtures each needs. Detail-page
  41. # ids are resolved from the seeded data at runtime (see seed()).
  42. ROUTE_PAGES = ("/", "/tasks", "/goals", "/projects", "/rules",
  43. "/stacks", "/discoveries", "/chats", "/saddles",
  44. "/users", "/models", "/profile")
  45. DETAIL_PAGES = ("/tasks/{task_id}", "/tasks/{task_id}/log",
  46. "/goals/{goal_id}", "/projects/{project_id}",
  47. "/stacks/{stack_id}", "/chats/{chat_id}")
  48. DEFAULT_WIDTHS = (360, 768, 1280)
  49. def find_chromium():
  50. for name in CHROME_CANDIDATES:
  51. path = shutil.which(name)
  52. if path:
  53. return path
  54. return None
  55. # ---------------------------------------------------------------------------
  56. # The in-page probe: runs inside the rendered page (Runtime.evaluate),
  57. # after statum has hydrated. Reports every horizontally-overflowing
  58. # element (scrollWidth > clientWidth + tolerance), page-level overflow,
  59. # tiny touch targets in the sticky chrome, and dialog widths.
  60. # ---------------------------------------------------------------------------
  61. PROBE_JS = r"""
  62. (() => {
  63. const out = {href: location.pathname, ready: !!(window.statum && window.statum.version),
  64. pageOverflowX: null, offenders: [], touch: [], dialogs: []};
  65. const de = document.documentElement;
  66. out.pageOverflowX = de.scrollWidth - de.clientWidth;
  67. const label = (el) => {
  68. if (el.id) return el.tagName.toLowerCase() + '#' + el.id;
  69. let sel = el.tagName.toLowerCase();
  70. if (el.classList && el.classList.length) sel += '.' + [...el.classList].slice(0, 3).join('.');
  71. return sel;
  72. };
  73. const path = (el) => {
  74. const parts = [];
  75. let node = el;
  76. while (node && node.nodeType === 1 && parts.length < 4) {
  77. parts.unshift(label(node));
  78. node = node.parentElement;
  79. }
  80. return parts.join(' > ');
  81. };
  82. const VISIBLE = (el) => {
  83. const cs = getComputedStyle(el);
  84. if (cs.display === 'none' || cs.visibility === 'hidden') return false;
  85. const r = el.getBoundingClientRect();
  86. return r.width > 0 && r.height > 0;
  87. };
  88. const all = document.querySelectorAll('*');
  89. // A box that sticks out past the viewport's right edge is a page
  90. // scroll contributor (in-flow or abs-positioned alike). Boxes fully
  91. // inside — including .table-wrap/.subnav/terminal that scroll
  92. // internally — are fine.
  93. const docW = de.clientWidth;
  94. const contained = (el) => {
  95. for (let a = el.parentElement; a; a = a.parentElement) {
  96. const ax = getComputedStyle(a).overflowX;
  97. if (ax !== 'visible') return true; // clipped/scrolling ancestor
  98. }
  99. return false;
  100. };
  101. for (const el of all) {
  102. if (!VISIBLE(el)) continue;
  103. if (contained(el)) continue;
  104. const r = el.getBoundingClientRect();
  105. if (r.right > docW + 0.5 && r.width > 0) {
  106. const cs = getComputedStyle(el);
  107. out.offenders.push({
  108. path: path(el), tag: el.tagName.toLowerCase(),
  109. right: Math.round(r.right), width: Math.round(r.width),
  110. over: Math.round(r.right - docW),
  111. pos: cs.position, white: cs.whiteSpace,
  112. });
  113. }
  114. }
  115. // Cap the offender list: 40 richest entries (biggest overflow first).
  116. out.offenders.sort((a, b) => b.over - a.over);
  117. out.offenders = out.offenders.slice(0, 40);
  118. // Touch targets: interactive controls in the sticky chrome + any
  119. // chips — must be >= 44px tall at phone widths to be tappable.
  120. const controls = document.querySelectorAll(
  121. 'button, [role=button], a.badge, .chip, summary, .dialog-close, ' +
  122. '.subnav a, .tabs label, label.choice, .btn');
  123. for (const el of controls) {
  124. if (!VISIBLE(el)) continue;
  125. // Checkboxes/radios ride inside label.choice — the LABEL is the
  126. // touch target; the input itself is intentionally small.
  127. if (el.tagName === 'INPUT') continue;
  128. const r = el.getBoundingClientRect();
  129. if (r.height > 0 && r.height < 44 && el.offsetParent !== null) {
  130. const cs = getComputedStyle(el);
  131. // Inline text links inside paragraphs are reading flow, not
  132. // controls — skip pure anchors without a class.
  133. if (el.tagName === 'A' && !el.className.trim()) continue;
  134. out.touch.push({path: path(el), h: Math.round(r.height),
  135. display: cs.display});
  136. }
  137. }
  138. out.touch = out.touch.slice(0, 30);
  139. // Dialog boxes: closed dialogs have no layout — measure one by
  140. // opening it if the page carries the app's dialog helper, then
  141. // close it again. The fit contract is spry's width: min(92vw, 24rem)
  142. // for .dialog-sm; an OPEN dialog must sit fully inside the viewport.
  143. if (window.openAppDialog) {
  144. const dlg = document.querySelector('dialog');
  145. if (dlg && !dlg.open) {
  146. try {
  147. window.openAppDialog(dlg.id);
  148. const r = dlg.getBoundingClientRect();
  149. out.dialogs.push({id: dlg.id || 'first', left: Math.round(r.left),
  150. right: Math.round(r.right),
  151. fits: r.right <= de.clientWidth + 0.5
  152. && r.left >= -0.5});
  153. dlg.close();
  154. } catch (e) { /* the page's own open hooks may differ */ }
  155. }
  156. }
  157. return out;
  158. })()
  159. """
  160. class Chrome:
  161. """Minimal CDP client over --headless --remote-debugging-port.
  162. Deliberately dependency-light: python3-websocket-client only (no
  163. selenium/playwright). One tab per audit; pages are navigated with
  164. Page.navigate + a settled-DOM wait loop.
  165. """
  166. def __init__(self, binary, port):
  167. self.log = open(os.path.join(tempfile.gettempdir(),
  168. "mobile-audit-chrome.log"), "w")
  169. self.proc = subprocess.Popen(
  170. [binary, "--headless=new", "--no-sandbox", "--disable-gpu",
  171. "--disable-dev-shm-usage", "--remote-debugging-port=%d" % port,
  172. "--user-data-dir=%s" % tempfile.mkdtemp(prefix="audit-chrome-"),
  173. "--no-first-run", "--remote-allow-origins=*",
  174. "about:blank"],
  175. stdout=self.log, stderr=subprocess.STDOUT)
  176. self.port = port
  177. self.ws = None
  178. self.msg_id = 0
  179. self._await_http(port)
  180. @staticmethod
  181. def _await_http(port, timeout=30):
  182. deadline = time.time() + timeout
  183. url = "http://127.0.0.1:%d/json/version" % port
  184. while time.time() < deadline:
  185. try:
  186. with urllib.request.urlopen(url, timeout=2) as response:
  187. if response.status == 200:
  188. return True
  189. except OSError:
  190. time.sleep(0.25)
  191. raise SystemExit("chromium devtools endpoint never came up on %d" % port)
  192. def connect(self):
  193. import websocket # python3-websocket-client
  194. with urllib.request.urlopen(
  195. "http://127.0.0.1:%d/json/list" % self.port) as response:
  196. targets = json.loads(response.read().decode())
  197. page = next(t for t in targets if t["type"] == "page")
  198. self.ws = websocket.create_connection(page["webSocketDebuggerUrl"],
  199. timeout=30)
  200. self.touch_media = False
  201. self._send("Target.createTarget" if False else "Page.enable")
  202. self._send("Runtime.enable")
  203. self._send("Network.enable")
  204. def _send(self, method, params=None, timeout=30):
  205. self.msg_id += 1
  206. self.ws.send(json.dumps({"id": self.msg_id, "method": method,
  207. "params": params or {}}))
  208. deadline = time.time() + timeout
  209. while time.time() < deadline:
  210. self.ws.settimeout(max(0.5, deadline - time.time()))
  211. try:
  212. message = json.loads(self.ws.recv())
  213. except websocket.WebSocketTimeoutException:
  214. break
  215. if message.get("id") == self.msg_id:
  216. return message
  217. return {"error": "timeout waiting for %s" % method}
  218. def _eval(self, expression, await_promise=False):
  219. result = self._send("Runtime.evaluate",
  220. {"expression": expression,
  221. "returnByValue": True,
  222. "awaitPromise": await_promise}, timeout=60)
  223. value = (result.get("result") or {}).get("result", {})
  224. return value.get("value")
  225. def add_init_script(self, script):
  226. self._send("Page.addScriptToEvaluateOnNewDocument", {"source": script})
  227. def set_viewport(self, width, height=900):
  228. # Coarse pointer at phone widths: headless reports pointer:fine
  229. # by default, which would hide every @media (pointer: coarse)
  230. # rule from the audit. Emulate a finger on phones so the touch
  231. # -target findings (and their fixes) are actually exercised.
  232. self.touch_media = width <= 500
  233. self._send("Emulation.setDeviceMetricsOverride",
  234. {"width": width, "height": height,
  235. "deviceScaleFactor": 1, "mobile": width <= 500})
  236. def goto(self, url, settle_s=3.0, timeout_s=45):
  237. """Navigate and wait for statum hydration + network quiet."""
  238. # Touch input at phone widths: headless reports NO pointer type
  239. # at all (both pointer:fine and pointer:coarse match nothing),
  240. # so Emulation.setEmulatedMedia cannot exercise the touch
  241. # rules. setTouchEmulationEnabled flips any-pointer: coarse,
  242. # which the CSS keys on (phones are any-pointer: coarse too).
  243. if self.touch_media:
  244. self._send("Emulation.setTouchEmulationEnabled",
  245. {"enabled": True, "maxTouchPoints": 5})
  246. self._send("Page.navigate", {"url": url}, timeout=60)
  247. deadline = time.time() + timeout_s
  248. hydrated = False
  249. while time.time() < deadline:
  250. state = self._eval(
  251. "document.readyState === 'complete' && "
  252. "!!(window.statum && window.statum.version) && "
  253. "(window.__auditHalt ? 'halt' : true)")
  254. if state == "halt":
  255. return False
  256. if state is True:
  257. hydrated = True
  258. break
  259. time.sleep(0.2)
  260. if not hydrated:
  261. print(" (warning: %s never fully hydrated)" % url)
  262. # Let afterEvaluate hooks, subnav engine and fonts settle.
  263. time.sleep(settle_s)
  264. return True
  265. def probe(self):
  266. return self._eval(PROBE_JS)
  267. def screenshot(self, path):
  268. self._send("Page.captureScreenshot", {"format": "png"}, timeout=60)
  269. def close(self):
  270. try:
  271. if self.ws:
  272. self.ws.close()
  273. self.proc.terminate()
  274. self.proc.wait(timeout=10)
  275. except OSError:
  276. pass
  277. self.log.close()
  278. # ---------------------------------------------------------------------------
  279. # Fixtures: representative data for every page family, planted through
  280. # the same wire/sqlite paths the bundles use.
  281. # ---------------------------------------------------------------------------
  282. def seed(ctx):
  283. """Returns the format-map for DETAIL_PAGES routes.
  284. Every write COMMITS immediately and every read opens a fresh
  285. connection: the wire act() calls below write through the app
  286. process, and a python-held open transaction would both hide their
  287. commits from this snapshot and block the app's writer.
  288. """
  289. auth = ctx.auth
  290. now = "strftime('%s','now')"
  291. def sql(statement, args=()):
  292. conn = harness.db()
  293. try:
  294. conn.execute(statement, args)
  295. conn.commit()
  296. finally:
  297. conn.close()
  298. def one(statement):
  299. conn = harness.db()
  300. try:
  301. return conn.execute(statement).fetchone()[0]
  302. finally:
  303. conn.close()
  304. # -- a second project (links) ------------------------------------
  305. sql("insert into projects (name, git_url, default_branch, summary,"
  306. " created_at) values ('audit-second',"
  307. " 'https://example.com/very/long/path/to/second/repo.git', 'main',"
  308. " 'A summary line for the second audit project.', %s)" % now)
  309. # -- tasks in every lifecycle shape -------------------------------
  310. for statement in (
  311. "insert into tasks (project_id, title, status, priority,"
  312. " created_at) values (1, 'Audit queued task', 'queued', 5,"
  313. " strftime('%s','now'))",
  314. "insert into tasks (project_id, title, status, priority,"
  315. " created_at) values (1, 'Audit working task', 'working', 5,"
  316. " strftime('%s','now'))",
  317. "insert into tasks (project_id, title, status, priority,"
  318. " created_at) values (1, 'Audit blocked task', 'blocked', 5,"
  319. " strftime('%s','now'))",
  320. "insert into tasks (project_id, title, status, priority, summary,"
  321. " completed_at, created_at) values (1, 'Audit done task with a"
  322. " very long completion summary heading', 'done', 5,"
  323. " 'Done: the audit harness landed cleanly\n\nBody paragraphs"
  324. " render under the headline.',"
  325. " strftime('%s','now'), strftime('%s','now','-60 seconds'))",
  326. "insert into tasks (project_id, title, status, priority,"
  327. " created_at) values (1, 'Audit failed task', 'failed', 5,"
  328. " strftime('%s','now'))"):
  329. sql(statement)
  330. task_id = one("select id from tasks where title = 'Audit working task'")
  331. blocked_id = one("select id from tasks where title = 'Audit blocked task'")
  332. # The blocked task owns a pending block (the pile surface) ...
  333. sql("insert into task_blocks (task_id, type, question, options,"
  334. " created_at) values (?, 'question',"
  335. " 'Which datastore should the audit harness back its fixtures"
  336. " with, given the fleet runs on cheap hardware?',"
  337. " '[\\\"SQLite\\\",\\\"Postgres\\\"]', %s)" % now, (blocked_id,))
  338. # ... and one answered block (the history fold).
  339. sql("insert into task_blocks (task_id, type, question, answer,"
  340. " answered_at, answered_by, created_at) values"
  341. " (?, 'question', 'Hold or fold?', 'Hold — the audit says the"
  342. " layout holds.', %s, 'user:admin', %s)" % (now, now), (blocked_id,))
  343. sql("insert into task_comments (task_id, author, body, created_at)"
  344. " values (?, 'user:admin', 'An audit comment with a quite long"
  345. " unbroken SSH-style token"
  346. " ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleVeryLongKeyBody"
  347. "ThatDoesNotWrap user@host', %s)" % now, (task_id,))
  348. # A working session on the working task + a rich event log.
  349. sql("insert into sessions (task_id, saddle_id, started_at)"
  350. " values (?, ?, %s)" % now, (task_id, ctx.fake_id))
  351. session_id = one("select max(id) from sessions")
  352. events = [
  353. ("comment", "user:admin", {"body": "Long comment body for the"
  354. " audit feed: " + "wrap-check-word " * 12}),
  355. ("tool_call", "model", {"tool": "run_command",
  356. "args": {"command": "meson setup build && ninja -C build"
  357. " && python3 test/web_stack_test.py ./build/clanker-saddle"
  358. " --bundle t7log --parallel 8"}}),
  359. ("tool_result", "model", {"result": "ok: the exit code was 0 and"
  360. " the output line was quite long indeed, well past the wrap point"
  361. " of a narrow terminal viewport 123456789012345678901234567890"}),
  362. ("progress", "model", {"text": "Overseeing the audit fixture"
  363. " fan-out with a long progress line."}),
  364. ("verdict", "model", {"verdict": "accept"}),
  365. ("turn_text", "model", {"text": "A turn of narrative text that"
  366. " spans a few sentences so the log feed has to wrap it in the"
  367. " narrow viewport."}),
  368. ("usage", "model", {"prompt_tokens": 12000, "completion_tokens": 900,
  369. "cached_tokens": 11000}),
  370. ]
  371. for seq, (kind, actor, payload) in enumerate(events, start=1):
  372. sql("insert into events (session_id, seq, kind, actor, scope_type,"
  373. " scope_id, payload, created_at) values (?, ?, ?, ?, 'task', ?,"
  374. " ?, %s - ?)" % now,
  375. (session_id, seq, kind, actor, task_id, json.dumps(payload), seq))
  376. # The blocked task carries an open block event so its pile card renders.
  377. sql("insert into events (session_id, seq, kind, actor, scope_type,"
  378. " scope_id, payload, created_at) values (NULL, 9001, 'question',"
  379. " 'model', 'task', ?, ?, %s)" % now,
  380. (blocked_id, json.dumps({"question": "Which datastore..."})))
  381. # -- a goal with the full review surface --------------------------
  382. # The honest wire flow (the goalui pattern): script the fake agent
  383. # to raise a plan review, create the goal, START it — the app then
  384. # mints the goal_run task and the agent parks blocked on the plan
  385. # question, which gives /goals/<id> its live review surface.
  386. ctx.fake.goal_script = [(
  387. "plan", "# Audit plan\n\n## Steps\n\n1. A very long numbered step"
  388. " that has to wrap inside the plan review card on a narrow screen"
  389. " without spilling sideways\n2. Second step")]
  390. goal_create = harness.slot_public(auth, "/goals", "goals")["create"]
  391. goal_id = None
  392. for attempt in range(3):
  393. harness.act(auth, goal_create, {
  394. "project_id": "1", "title": "Audit goal with a long title that"
  395. " must wrap on a narrow phone viewport without spilling",
  396. "brief": "Drive the mobile audit end to end across the fleet's"
  397. " surfaces with representative fixture depth.",
  398. "branch_name": "goal/audit-t12"})
  399. goal_id = harness.wait_for(
  400. lambda: (one("select count(*) from goals where title like"
  401. " 'Audit goal%'") and "yes") or False,
  402. timeout=10, what="the audit goal row") and one(
  403. "select id from goals where title like 'Audit goal%'")
  404. if goal_id:
  405. break
  406. # A dispatch tick can hold the writer; retry the create once.
  407. time.sleep(1)
  408. harness.act(auth, harness.goal_state(goal_id)["start"], {})
  409. harness.wait_for(
  410. lambda: (one("select count(*) from tasks where goal_id = %d"
  411. " and kind = 'goal_run'" % goal_id)
  412. and "yes") or False,
  413. timeout=60, what="the audit goal's run task")
  414. run_task = one("select id from tasks where goal_id = %d"
  415. " and kind = 'goal_run'" % goal_id)
  416. harness.wait_for(
  417. lambda: (one("select status from tasks where id = %d" % run_task)
  418. == "blocked" and "yes") or False,
  419. timeout=60, what="the audit goal agent parks on its plan review")
  420. # One answered plan revision (the history fold surface).
  421. sql("insert into task_blocks (task_id, type, question, answer,"
  422. " answered_at, answered_by, created_at) values (?, 'plan',"
  423. " '# Earlier plan revision\n\nOlder body.', 'Add a step for"
  424. " the mobile audit, please.', %s, 'user:admin', %s)" % (now, now),
  425. (run_task,))
  426. # -- stacks --------------------------------------------------------
  427. stacks_create = harness.slot_public(auth, "/stacks", "stacks")["create"]
  428. harness.act(auth, stacks_create, {"name": "audit-stack"})
  429. stack_id = one("select id from stacks where name = 'audit-stack'")
  430. # -- rules ---------------------------------------------------------
  431. rules_create = harness.slot_public(auth, "/rules", "rules")["create"]
  432. harness.act(auth, rules_create, {
  433. "name": "audit-rule",
  434. "body": "Always build with meson before running the suite; the"
  435. " canonical builddir is ./builddir for canon_deploy."})
  436. # -- chats (advisor) -------------------------------------------------
  437. sql("insert into advisor_chats (user_id, title, status, created_at,"
  438. " updated_at) values (1, 'Audit chat about layout widths',"
  439. " 'active', %s, %s)" % (now, now))
  440. chat_id = one("select max(id) from advisor_chats")
  441. for kind, content in (
  442. ("user", "What widths should the audit check for horizontal"
  443. " overflow?"),
  444. ("assistant", "360 for phones, 768 for tablets, 1280 for"
  445. " desktops — the audit probes all three."),
  446. ("question", "Which bundler do you use for the audit wire?")):
  447. sql("insert into advisor_messages (chat_id, kind, content,"
  448. " created_at) values (?, ?, ?, %s)" % now,
  449. (chat_id, kind, content))
  450. # -- discoveries ----------------------------------------------------
  451. sql("insert into discoveries (body, source_task_id, created_at,"
  452. " updated_at) values (?, ?, %s, %s)" % (now, now),
  453. ("**Chromium headless tip** — the CDP probe pattern: seed the"
  454. " statum auth frame into localStorage via"
  455. " Page.addScriptToEvaluateOnNewDocument before statum.js runs"
  456. " and every page hydrates fully. body/"
  457. "wouldyouwrapthislongunbrokentoken"
  458. "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAnotherExtremelyLongKey"
  459. "ForWrapTesting user@host", task_id))
  460. return {"task_id": task_id, "goal_id": goal_id,
  461. "project_id": 2, "stack_id": stack_id, "chat_id": chat_id}
  462. def run_audit(app, widths, routes=None, json_out=None, keep=False):
  463. chromium = find_chromium()
  464. if not chromium:
  465. print("SKIP no chromium binary found (looked for: %s)"
  466. % ", ".join(CHROME_CANDIDATES))
  467. return None
  468. ctx = harness.start_bundle("t12mobile")
  469. harness.CTX = ctx # goal_state() reads the module's current context
  470. try:
  471. ids = seed(ctx)
  472. except Exception:
  473. # Never orphan the app process on a seeding failure — a later
  474. # free_port() TOCTOU then kills the NEXT run's app at bind.
  475. try:
  476. ctx.app.terminate()
  477. ctx.app.wait(timeout=10)
  478. except OSError:
  479. pass
  480. raise
  481. auth = ctx.auth
  482. # Re-derive the auth frame exactly as the browser would hold it:
  483. # the login POST's set directive carries snapshot_frame{content}.
  484. frames = harness.directives("/login")
  485. content = json.loads(frames[0]["snapshot_frame"]["content"])
  486. login_action = content["public"]["login"]
  487. status, body = harness.http("POST", login_action["uri"],
  488. {"username": "admin",
  489. "password": "adminpass123"})
  490. auth_frame = None
  491. for directive in json.loads(body):
  492. if directive["type"] == "set":
  493. frame = json.loads(directive["snapshot_frame"]["content"])
  494. if frame["slot"]["type"] == "auth":
  495. auth_frame = directive["snapshot_frame"]
  496. if auth_frame is None:
  497. raise SystemExit("no auth frame in login response")
  498. port = harness.free_port()
  499. chrome = Chrome(chromium, port)
  500. try:
  501. chrome.connect()
  502. # Seed localStorage BEFORE any page script runs. The frame is
  503. # the exact JSON statum.js persists (persistEntry: the frame,
  504. # not the snapshot).
  505. chrome.add_init_script(
  506. "try { localStorage.setItem('statum:auth', %s); } catch (e) {}"
  507. % json.dumps(json.dumps(auth_frame)))
  508. chrome.set_viewport(widths[0])
  509. all_routes = [r.format(**ids) for r in
  510. (routes or (list(ROUTE_PAGES) + list(DETAIL_PAGES)))]
  511. findings = {}
  512. for route in all_routes:
  513. findings[route] = {}
  514. for width in widths:
  515. chrome.set_viewport(width)
  516. ok = chrome.goto(harness.WEB + route)
  517. if not ok:
  518. findings[route][width] = {"error": "navigation halted"}
  519. continue
  520. findings[route][width] = chrome.probe() or {}
  521. findings[route][width]["url"] = route
  522. findings[route][width]["width"] = width
  523. if json_out:
  524. with open(json_out, "w") as handle:
  525. json.dump(findings, handle, indent=1)
  526. return findings
  527. finally:
  528. chrome.close()
  529. if not keep:
  530. try:
  531. ctx.app.terminate()
  532. ctx.app.wait(timeout=10)
  533. except OSError:
  534. pass
  535. try:
  536. ctx.fake.shutdown()
  537. except AttributeError:
  538. pass
  539. def summarise(findings):
  540. """Prints the per-page/width report; returns a flat finding list."""
  541. rows = []
  542. dialog_bad = []
  543. print("\n%-34s %6s %8s %s" % ("route", "width", "pageDX", "offenders"))
  544. for route in sorted(findings):
  545. for width in sorted(findings[route]):
  546. data = findings[route][width]
  547. if "error" in data:
  548. print("%-34s %6d ERROR %s" % (route, width, data["error"]))
  549. rows.append((route, width, "error", data["error"]))
  550. continue
  551. page_dx = data.get("pageOverflowX") or 0
  552. offenders = data.get("offenders") or []
  553. touch = data.get("touch") or []
  554. for dialog in data.get("dialogs") or []:
  555. if not dialog.get("fits", True):
  556. dialog_bad.append(
  557. (route, width, "dialog %s spills (right=%s)"
  558. % (dialog.get("id"), dialog.get("right"))))
  559. mark = "" if (page_dx <= 0 and not offenders) else " <-- FIX"
  560. print("%-34s %6d %8d %3d%s" % (route, width, page_dx,
  561. len(offenders), mark))
  562. for offender in offenders:
  563. rows.append((route, width, "overflow",
  564. "%s right=%d w=%d (%+d) pos=%s ws=%s"
  565. % (offender["path"], offender.get("right", 0),
  566. offender.get("width", 0), offender["over"],
  567. offender.get("pos"), offender.get("white"))))
  568. for target in touch:
  569. rows.append((route, width, "touch",
  570. "%s h=%dpx" % (target["path"], target["h"])))
  571. for route, width, note in dialog_bad:
  572. rows.append((route, width, "dialog", note))
  573. print(" DIALOG %s @%s %s" % (route, width, note))
  574. return rows
  575. def main():
  576. app = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-") \
  577. else os.path.join(harness.REPO, "builddir", "clanker-saddle")
  578. sys.argv = [a for a in sys.argv
  579. if a != app or sys.argv.index(a) == 0]
  580. widths = DEFAULT_WIDTHS
  581. routes = None
  582. json_out = os.environ.get("MOBILE_AUDIT_JSON")
  583. keep = False
  584. args = sys.argv[1:]
  585. i = 0
  586. while i < len(args):
  587. if args[i] == "--widths":
  588. widths = [int(w) for w in args[i + 1].split(",")]
  589. i += 2
  590. elif args[i] == "--routes":
  591. routes = args[i + 1].split(",")
  592. i += 2
  593. elif args[i] == "--json":
  594. json_out = args[i + 1]
  595. i += 2
  596. elif args[i] == "--keep":
  597. keep = True
  598. i += 1
  599. else:
  600. i += 1
  601. if not os.path.exists(app):
  602. raise SystemExit("app binary not found at %s (build first)" % app)
  603. findings = run_audit(app, widths, routes, json_out, keep)
  604. if findings is None:
  605. return 0
  606. rows = summarise(findings)
  607. overflow = [r for r in rows if r[2] == "overflow"]
  608. dialogs = [r for r in rows if r[2] == "dialog"]
  609. touch = [r for r in rows if r[2] == "touch"]
  610. print("\nSUMMARY: %d overflow findings, %d dialog-fit findings,"
  611. " %d sub-44px touch targets"
  612. % (len(overflow), len(dialogs), len(touch)))
  613. for row in overflow:
  614. print(" OVERFLOW %s @%s %s" % (row[0], row[1], row[3]))
  615. for row in dialogs:
  616. print(" DIALOG %s @%s %s" % (row[0], row[1], row[3]))
  617. for row in touch:
  618. print(" TOUCH %s @%s %s" % (row[0], row[1], row[3]))
  619. return 1 if (overflow or dialogs) else 0
  620. if __name__ == "__main__":
  621. sys.exit(main())