| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680 |
- #!/usr/bin/env python3
- """The mobile/viewport layout audit (goal 41 T12).
- Boots the REAL app through the e2e harness (start_bundle: real binary,
- real statum wire, fake saddle daemon), seeds representative data for
- every page family, then renders each route in headless Chromium at
- three widths (360 = phone, 768 = tablet, 1280 = desktop) and reports
- every element that overflows its box horizontally, plus page-level
- horizontal scrolling.
- Rendering is FULL hydration, not static HTML: the audit seeds the
- harness login's auth frame into localStorage via the Chrome DevTools
- Protocol (Page.addScriptToEvaluateOnNewDocument) BEFORE statum.js runs,
- so every stm-* template evaluates against real server state, CSS
- applies, and client engines (sub-nav, log feed, filters) boot.
- Usage:
- python3 test/mobile_audit.py [app_binary] [--json OUT.json]
- [--widths 360,768,1280] [--routes a,b] [--keep]
- Exit code 0 = zero findings. chromium-browser/chromium is required; the
- script tells you when it is missing (the t12mobile bundle gates on the
- same probe and skips its browser part there).
- """
- import json
- import os
- import shutil
- import subprocess
- import sys
- import tempfile
- import time
- import urllib.request
- HERE = os.path.dirname(os.path.abspath(__file__))
- sys.path.insert(0, HERE)
- import harness # noqa: E402 (module-level import: sets REPO/APP defaults)
- # The harness's default scratch dir is a top-level /tmp path the
- # janitor reaps mid-run (a known fleet gotcha) — default to a
- # repo-local one unless the caller set their own.
- if "WEBSTACK_WORK" not in os.environ:
- os.environ["WEBSTACK_WORK"] = os.path.join(
- harness.REPO, "build", "ws-mobile")
- CHROME_CANDIDATES = ("chromium-browser", "chromium", "google-chrome",
- "google-chrome-stable")
- # Every route the app ships, with the fixtures each needs. Detail-page
- # ids are resolved from the seeded data at runtime (see seed()).
- ROUTE_PAGES = ("/", "/tasks", "/goals", "/projects", "/rules",
- "/stacks", "/discoveries", "/chats", "/saddles",
- "/users", "/models", "/profile")
- DETAIL_PAGES = ("/tasks/{task_id}", "/tasks/{task_id}/log",
- "/goals/{goal_id}", "/projects/{project_id}",
- "/stacks/{stack_id}", "/chats/{chat_id}")
- DEFAULT_WIDTHS = (360, 768, 1280)
- def find_chromium():
- for name in CHROME_CANDIDATES:
- path = shutil.which(name)
- if path:
- return path
- return None
- # ---------------------------------------------------------------------------
- # The in-page probe: runs inside the rendered page (Runtime.evaluate),
- # after statum has hydrated. Reports every horizontally-overflowing
- # element (scrollWidth > clientWidth + tolerance), page-level overflow,
- # tiny touch targets in the sticky chrome, and dialog widths.
- # ---------------------------------------------------------------------------
- PROBE_JS = r"""
- (() => {
- const out = {href: location.pathname, ready: !!(window.statum && window.statum.version),
- pageOverflowX: null, offenders: [], touch: [], dialogs: []};
- const de = document.documentElement;
- out.pageOverflowX = de.scrollWidth - de.clientWidth;
- const label = (el) => {
- if (el.id) return el.tagName.toLowerCase() + '#' + el.id;
- let sel = el.tagName.toLowerCase();
- if (el.classList && el.classList.length) sel += '.' + [...el.classList].slice(0, 3).join('.');
- return sel;
- };
- const path = (el) => {
- const parts = [];
- let node = el;
- while (node && node.nodeType === 1 && parts.length < 4) {
- parts.unshift(label(node));
- node = node.parentElement;
- }
- return parts.join(' > ');
- };
- const VISIBLE = (el) => {
- const cs = getComputedStyle(el);
- if (cs.display === 'none' || cs.visibility === 'hidden') return false;
- const r = el.getBoundingClientRect();
- return r.width > 0 && r.height > 0;
- };
- const all = document.querySelectorAll('*');
- // A box that sticks out past the viewport's right edge is a page
- // scroll contributor (in-flow or abs-positioned alike). Boxes fully
- // inside — including .table-wrap/.subnav/terminal that scroll
- // internally — are fine.
- const docW = de.clientWidth;
- const contained = (el) => {
- for (let a = el.parentElement; a; a = a.parentElement) {
- const ax = getComputedStyle(a).overflowX;
- if (ax !== 'visible') return true; // clipped/scrolling ancestor
- }
- return false;
- };
- for (const el of all) {
- if (!VISIBLE(el)) continue;
- if (contained(el)) continue;
- const r = el.getBoundingClientRect();
- if (r.right > docW + 0.5 && r.width > 0) {
- const cs = getComputedStyle(el);
- out.offenders.push({
- path: path(el), tag: el.tagName.toLowerCase(),
- right: Math.round(r.right), width: Math.round(r.width),
- over: Math.round(r.right - docW),
- pos: cs.position, white: cs.whiteSpace,
- });
- }
- }
- // Cap the offender list: 40 richest entries (biggest overflow first).
- out.offenders.sort((a, b) => b.over - a.over);
- out.offenders = out.offenders.slice(0, 40);
- // Touch targets: interactive controls in the sticky chrome + any
- // chips — must be >= 44px tall at phone widths to be tappable.
- const controls = document.querySelectorAll(
- 'button, [role=button], a.badge, .chip, summary, .dialog-close, ' +
- '.subnav a, .tabs label, label.choice, .btn');
- for (const el of controls) {
- if (!VISIBLE(el)) continue;
- // Checkboxes/radios ride inside label.choice — the LABEL is the
- // touch target; the input itself is intentionally small.
- if (el.tagName === 'INPUT') continue;
- const r = el.getBoundingClientRect();
- if (r.height > 0 && r.height < 44 && el.offsetParent !== null) {
- const cs = getComputedStyle(el);
- // Inline text links inside paragraphs are reading flow, not
- // controls — skip pure anchors without a class.
- if (el.tagName === 'A' && !el.className.trim()) continue;
- out.touch.push({path: path(el), h: Math.round(r.height),
- display: cs.display});
- }
- }
- out.touch = out.touch.slice(0, 30);
- // Dialog boxes: closed dialogs have no layout — measure one by
- // opening it if the page carries the app's dialog helper, then
- // close it again. The fit contract is spry's width: min(92vw, 24rem)
- // for .dialog-sm; an OPEN dialog must sit fully inside the viewport.
- if (window.openAppDialog) {
- const dlg = document.querySelector('dialog');
- if (dlg && !dlg.open) {
- try {
- window.openAppDialog(dlg.id);
- const r = dlg.getBoundingClientRect();
- out.dialogs.push({id: dlg.id || 'first', left: Math.round(r.left),
- right: Math.round(r.right),
- fits: r.right <= de.clientWidth + 0.5
- && r.left >= -0.5});
- dlg.close();
- } catch (e) { /* the page's own open hooks may differ */ }
- }
- }
- return out;
- })()
- """
- class Chrome:
- """Minimal CDP client over --headless --remote-debugging-port.
- Deliberately dependency-light: python3-websocket-client only (no
- selenium/playwright). One tab per audit; pages are navigated with
- Page.navigate + a settled-DOM wait loop.
- """
- def __init__(self, binary, port):
- self.log = open(os.path.join(tempfile.gettempdir(),
- "mobile-audit-chrome.log"), "w")
- self.proc = subprocess.Popen(
- [binary, "--headless=new", "--no-sandbox", "--disable-gpu",
- "--disable-dev-shm-usage", "--remote-debugging-port=%d" % port,
- "--user-data-dir=%s" % tempfile.mkdtemp(prefix="audit-chrome-"),
- "--no-first-run", "--remote-allow-origins=*",
- "about:blank"],
- stdout=self.log, stderr=subprocess.STDOUT)
- self.port = port
- self.ws = None
- self.msg_id = 0
- self._await_http(port)
- @staticmethod
- def _await_http(port, timeout=30):
- deadline = time.time() + timeout
- url = "http://127.0.0.1:%d/json/version" % port
- while time.time() < deadline:
- try:
- with urllib.request.urlopen(url, timeout=2) as response:
- if response.status == 200:
- return True
- except OSError:
- time.sleep(0.25)
- raise SystemExit("chromium devtools endpoint never came up on %d" % port)
- def connect(self):
- import websocket # python3-websocket-client
- with urllib.request.urlopen(
- "http://127.0.0.1:%d/json/list" % self.port) as response:
- targets = json.loads(response.read().decode())
- page = next(t for t in targets if t["type"] == "page")
- self.ws = websocket.create_connection(page["webSocketDebuggerUrl"],
- timeout=30)
- self.touch_media = False
- self._send("Target.createTarget" if False else "Page.enable")
- self._send("Runtime.enable")
- self._send("Network.enable")
- def _send(self, method, params=None, timeout=30):
- self.msg_id += 1
- self.ws.send(json.dumps({"id": self.msg_id, "method": method,
- "params": params or {}}))
- deadline = time.time() + timeout
- while time.time() < deadline:
- self.ws.settimeout(max(0.5, deadline - time.time()))
- try:
- message = json.loads(self.ws.recv())
- except websocket.WebSocketTimeoutException:
- break
- if message.get("id") == self.msg_id:
- return message
- return {"error": "timeout waiting for %s" % method}
- def _eval(self, expression, await_promise=False):
- result = self._send("Runtime.evaluate",
- {"expression": expression,
- "returnByValue": True,
- "awaitPromise": await_promise}, timeout=60)
- value = (result.get("result") or {}).get("result", {})
- return value.get("value")
- def add_init_script(self, script):
- self._send("Page.addScriptToEvaluateOnNewDocument", {"source": script})
- def set_viewport(self, width, height=900):
- # Coarse pointer at phone widths: headless reports pointer:fine
- # by default, which would hide every @media (pointer: coarse)
- # rule from the audit. Emulate a finger on phones so the touch
- # -target findings (and their fixes) are actually exercised.
- self.touch_media = width <= 500
- self._send("Emulation.setDeviceMetricsOverride",
- {"width": width, "height": height,
- "deviceScaleFactor": 1, "mobile": width <= 500})
- def goto(self, url, settle_s=3.0, timeout_s=45):
- """Navigate and wait for statum hydration + network quiet."""
- # Touch input at phone widths: headless reports NO pointer type
- # at all (both pointer:fine and pointer:coarse match nothing),
- # so Emulation.setEmulatedMedia cannot exercise the touch
- # rules. setTouchEmulationEnabled flips any-pointer: coarse,
- # which the CSS keys on (phones are any-pointer: coarse too).
- if self.touch_media:
- self._send("Emulation.setTouchEmulationEnabled",
- {"enabled": True, "maxTouchPoints": 5})
- self._send("Page.navigate", {"url": url}, timeout=60)
- deadline = time.time() + timeout_s
- hydrated = False
- while time.time() < deadline:
- state = self._eval(
- "document.readyState === 'complete' && "
- "!!(window.statum && window.statum.version) && "
- "(window.__auditHalt ? 'halt' : true)")
- if state == "halt":
- return False
- if state is True:
- hydrated = True
- break
- time.sleep(0.2)
- if not hydrated:
- print(" (warning: %s never fully hydrated)" % url)
- # Let afterEvaluate hooks, subnav engine and fonts settle.
- time.sleep(settle_s)
- return True
- def probe(self):
- return self._eval(PROBE_JS)
- def screenshot(self, path):
- self._send("Page.captureScreenshot", {"format": "png"}, timeout=60)
- def close(self):
- try:
- if self.ws:
- self.ws.close()
- self.proc.terminate()
- self.proc.wait(timeout=10)
- except OSError:
- pass
- self.log.close()
- # ---------------------------------------------------------------------------
- # Fixtures: representative data for every page family, planted through
- # the same wire/sqlite paths the bundles use.
- # ---------------------------------------------------------------------------
- def seed(ctx):
- """Returns the format-map for DETAIL_PAGES routes.
- Every write COMMITS immediately and every read opens a fresh
- connection: the wire act() calls below write through the app
- process, and a python-held open transaction would both hide their
- commits from this snapshot and block the app's writer.
- """
- auth = ctx.auth
- now = "strftime('%s','now')"
- def sql(statement, args=()):
- conn = harness.db()
- try:
- conn.execute(statement, args)
- conn.commit()
- finally:
- conn.close()
- def one(statement):
- conn = harness.db()
- try:
- return conn.execute(statement).fetchone()[0]
- finally:
- conn.close()
- # -- a second project (links) ------------------------------------
- sql("insert into projects (name, git_url, default_branch, summary,"
- " created_at) values ('audit-second',"
- " 'https://example.com/very/long/path/to/second/repo.git', 'main',"
- " 'A summary line for the second audit project.', %s)" % now)
- # -- tasks in every lifecycle shape -------------------------------
- for statement in (
- "insert into tasks (project_id, title, status, priority,"
- " created_at) values (1, 'Audit queued task', 'queued', 5,"
- " strftime('%s','now'))",
- "insert into tasks (project_id, title, status, priority,"
- " created_at) values (1, 'Audit working task', 'working', 5,"
- " strftime('%s','now'))",
- "insert into tasks (project_id, title, status, priority,"
- " created_at) values (1, 'Audit blocked task', 'blocked', 5,"
- " strftime('%s','now'))",
- "insert into tasks (project_id, title, status, priority, summary,"
- " completed_at, created_at) values (1, 'Audit done task with a"
- " very long completion summary heading', 'done', 5,"
- " 'Done: the audit harness landed cleanly\n\nBody paragraphs"
- " render under the headline.',"
- " strftime('%s','now'), strftime('%s','now','-60 seconds'))",
- "insert into tasks (project_id, title, status, priority,"
- " created_at) values (1, 'Audit failed task', 'failed', 5,"
- " strftime('%s','now'))"):
- sql(statement)
- task_id = one("select id from tasks where title = 'Audit working task'")
- blocked_id = one("select id from tasks where title = 'Audit blocked task'")
- # The blocked task owns a pending block (the pile surface) ...
- sql("insert into task_blocks (task_id, type, question, options,"
- " created_at) values (?, 'question',"
- " 'Which datastore should the audit harness back its fixtures"
- " with, given the fleet runs on cheap hardware?',"
- " '[\\\"SQLite\\\",\\\"Postgres\\\"]', %s)" % now, (blocked_id,))
- # ... and one answered block (the history fold).
- sql("insert into task_blocks (task_id, type, question, answer,"
- " answered_at, answered_by, created_at) values"
- " (?, 'question', 'Hold or fold?', 'Hold — the audit says the"
- " layout holds.', %s, 'user:admin', %s)" % (now, now), (blocked_id,))
- sql("insert into task_comments (task_id, author, body, created_at)"
- " values (?, 'user:admin', 'An audit comment with a quite long"
- " unbroken SSH-style token"
- " ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleVeryLongKeyBody"
- "ThatDoesNotWrap user@host', %s)" % now, (task_id,))
- # A working session on the working task + a rich event log.
- sql("insert into sessions (task_id, saddle_id, started_at)"
- " values (?, ?, %s)" % now, (task_id, ctx.fake_id))
- session_id = one("select max(id) from sessions")
- events = [
- ("comment", "user:admin", {"body": "Long comment body for the"
- " audit feed: " + "wrap-check-word " * 12}),
- ("tool_call", "model", {"tool": "run_command",
- "args": {"command": "meson setup build && ninja -C build"
- " && python3 test/web_stack_test.py ./build/clanker-saddle"
- " --bundle t7log --parallel 8"}}),
- ("tool_result", "model", {"result": "ok: the exit code was 0 and"
- " the output line was quite long indeed, well past the wrap point"
- " of a narrow terminal viewport 123456789012345678901234567890"}),
- ("progress", "model", {"text": "Overseeing the audit fixture"
- " fan-out with a long progress line."}),
- ("verdict", "model", {"verdict": "accept"}),
- ("turn_text", "model", {"text": "A turn of narrative text that"
- " spans a few sentences so the log feed has to wrap it in the"
- " narrow viewport."}),
- ("usage", "model", {"prompt_tokens": 12000, "completion_tokens": 900,
- "cached_tokens": 11000}),
- ]
- for seq, (kind, actor, payload) in enumerate(events, start=1):
- sql("insert into events (session_id, seq, kind, actor, scope_type,"
- " scope_id, payload, created_at) values (?, ?, ?, ?, 'task', ?,"
- " ?, %s - ?)" % now,
- (session_id, seq, kind, actor, task_id, json.dumps(payload), seq))
- # The blocked task carries an open block event so its pile card renders.
- sql("insert into events (session_id, seq, kind, actor, scope_type,"
- " scope_id, payload, created_at) values (NULL, 9001, 'question',"
- " 'model', 'task', ?, ?, %s)" % now,
- (blocked_id, json.dumps({"question": "Which datastore..."})))
- # -- a goal with the full review surface --------------------------
- # The honest wire flow (the goalui pattern): script the fake agent
- # to raise a plan review, create the goal, START it — the app then
- # mints the goal_run task and the agent parks blocked on the plan
- # question, which gives /goals/<id> its live review surface.
- ctx.fake.goal_script = [(
- "plan", "# Audit plan\n\n## Steps\n\n1. A very long numbered step"
- " that has to wrap inside the plan review card on a narrow screen"
- " without spilling sideways\n2. Second step")]
- goal_create = harness.slot_public(auth, "/goals", "goals")["create"]
- goal_id = None
- for attempt in range(3):
- harness.act(auth, goal_create, {
- "project_id": "1", "title": "Audit goal with a long title that"
- " must wrap on a narrow phone viewport without spilling",
- "brief": "Drive the mobile audit end to end across the fleet's"
- " surfaces with representative fixture depth.",
- "branch_name": "goal/audit-t12"})
- goal_id = harness.wait_for(
- lambda: (one("select count(*) from goals where title like"
- " 'Audit goal%'") and "yes") or False,
- timeout=10, what="the audit goal row") and one(
- "select id from goals where title like 'Audit goal%'")
- if goal_id:
- break
- # A dispatch tick can hold the writer; retry the create once.
- time.sleep(1)
- harness.act(auth, harness.goal_state(goal_id)["start"], {})
- harness.wait_for(
- lambda: (one("select count(*) from tasks where goal_id = %d"
- " and kind = 'goal_run'" % goal_id)
- and "yes") or False,
- timeout=60, what="the audit goal's run task")
- run_task = one("select id from tasks where goal_id = %d"
- " and kind = 'goal_run'" % goal_id)
- harness.wait_for(
- lambda: (one("select status from tasks where id = %d" % run_task)
- == "blocked" and "yes") or False,
- timeout=60, what="the audit goal agent parks on its plan review")
- # One answered plan revision (the history fold surface).
- sql("insert into task_blocks (task_id, type, question, answer,"
- " answered_at, answered_by, created_at) values (?, 'plan',"
- " '# Earlier plan revision\n\nOlder body.', 'Add a step for"
- " the mobile audit, please.', %s, 'user:admin', %s)" % (now, now),
- (run_task,))
- # -- stacks --------------------------------------------------------
- stacks_create = harness.slot_public(auth, "/stacks", "stacks")["create"]
- harness.act(auth, stacks_create, {"name": "audit-stack"})
- stack_id = one("select id from stacks where name = 'audit-stack'")
- # -- rules ---------------------------------------------------------
- rules_create = harness.slot_public(auth, "/rules", "rules")["create"]
- harness.act(auth, rules_create, {
- "name": "audit-rule",
- "body": "Always build with meson before running the suite; the"
- " canonical builddir is ./builddir for canon_deploy."})
- # -- chats (advisor) -------------------------------------------------
- sql("insert into advisor_chats (user_id, title, status, created_at,"
- " updated_at) values (1, 'Audit chat about layout widths',"
- " 'active', %s, %s)" % (now, now))
- chat_id = one("select max(id) from advisor_chats")
- for kind, content in (
- ("user", "What widths should the audit check for horizontal"
- " overflow?"),
- ("assistant", "360 for phones, 768 for tablets, 1280 for"
- " desktops — the audit probes all three."),
- ("question", "Which bundler do you use for the audit wire?")):
- sql("insert into advisor_messages (chat_id, kind, content,"
- " created_at) values (?, ?, ?, %s)" % now,
- (chat_id, kind, content))
- # -- discoveries ----------------------------------------------------
- sql("insert into discoveries (body, source_task_id, created_at,"
- " updated_at) values (?, ?, %s, %s)" % (now, now),
- ("**Chromium headless tip** — the CDP probe pattern: seed the"
- " statum auth frame into localStorage via"
- " Page.addScriptToEvaluateOnNewDocument before statum.js runs"
- " and every page hydrates fully. body/"
- "wouldyouwrapthislongunbrokentoken"
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAnotherExtremelyLongKey"
- "ForWrapTesting user@host", task_id))
- return {"task_id": task_id, "goal_id": goal_id,
- "project_id": 2, "stack_id": stack_id, "chat_id": chat_id}
- def run_audit(app, widths, routes=None, json_out=None, keep=False):
- chromium = find_chromium()
- if not chromium:
- print("SKIP no chromium binary found (looked for: %s)"
- % ", ".join(CHROME_CANDIDATES))
- return None
- ctx = harness.start_bundle("t12mobile")
- harness.CTX = ctx # goal_state() reads the module's current context
- try:
- ids = seed(ctx)
- except Exception:
- # Never orphan the app process on a seeding failure — a later
- # free_port() TOCTOU then kills the NEXT run's app at bind.
- try:
- ctx.app.terminate()
- ctx.app.wait(timeout=10)
- except OSError:
- pass
- raise
- auth = ctx.auth
- # Re-derive the auth frame exactly as the browser would hold it:
- # the login POST's set directive carries snapshot_frame{content}.
- frames = harness.directives("/login")
- content = json.loads(frames[0]["snapshot_frame"]["content"])
- login_action = content["public"]["login"]
- status, body = harness.http("POST", login_action["uri"],
- {"username": "admin",
- "password": "adminpass123"})
- auth_frame = None
- for directive in json.loads(body):
- if directive["type"] == "set":
- frame = json.loads(directive["snapshot_frame"]["content"])
- if frame["slot"]["type"] == "auth":
- auth_frame = directive["snapshot_frame"]
- if auth_frame is None:
- raise SystemExit("no auth frame in login response")
- port = harness.free_port()
- chrome = Chrome(chromium, port)
- try:
- chrome.connect()
- # Seed localStorage BEFORE any page script runs. The frame is
- # the exact JSON statum.js persists (persistEntry: the frame,
- # not the snapshot).
- chrome.add_init_script(
- "try { localStorage.setItem('statum:auth', %s); } catch (e) {}"
- % json.dumps(json.dumps(auth_frame)))
- chrome.set_viewport(widths[0])
- all_routes = [r.format(**ids) for r in
- (routes or (list(ROUTE_PAGES) + list(DETAIL_PAGES)))]
- findings = {}
- for route in all_routes:
- findings[route] = {}
- for width in widths:
- chrome.set_viewport(width)
- ok = chrome.goto(harness.WEB + route)
- if not ok:
- findings[route][width] = {"error": "navigation halted"}
- continue
- findings[route][width] = chrome.probe() or {}
- findings[route][width]["url"] = route
- findings[route][width]["width"] = width
- if json_out:
- with open(json_out, "w") as handle:
- json.dump(findings, handle, indent=1)
- return findings
- finally:
- chrome.close()
- if not keep:
- try:
- ctx.app.terminate()
- ctx.app.wait(timeout=10)
- except OSError:
- pass
- try:
- ctx.fake.shutdown()
- except AttributeError:
- pass
- def summarise(findings):
- """Prints the per-page/width report; returns a flat finding list."""
- rows = []
- dialog_bad = []
- print("\n%-34s %6s %8s %s" % ("route", "width", "pageDX", "offenders"))
- for route in sorted(findings):
- for width in sorted(findings[route]):
- data = findings[route][width]
- if "error" in data:
- print("%-34s %6d ERROR %s" % (route, width, data["error"]))
- rows.append((route, width, "error", data["error"]))
- continue
- page_dx = data.get("pageOverflowX") or 0
- offenders = data.get("offenders") or []
- touch = data.get("touch") or []
- for dialog in data.get("dialogs") or []:
- if not dialog.get("fits", True):
- dialog_bad.append(
- (route, width, "dialog %s spills (right=%s)"
- % (dialog.get("id"), dialog.get("right"))))
- mark = "" if (page_dx <= 0 and not offenders) else " <-- FIX"
- print("%-34s %6d %8d %3d%s" % (route, width, page_dx,
- len(offenders), mark))
- for offender in offenders:
- rows.append((route, width, "overflow",
- "%s right=%d w=%d (%+d) pos=%s ws=%s"
- % (offender["path"], offender.get("right", 0),
- offender.get("width", 0), offender["over"],
- offender.get("pos"), offender.get("white"))))
- for target in touch:
- rows.append((route, width, "touch",
- "%s h=%dpx" % (target["path"], target["h"])))
- for route, width, note in dialog_bad:
- rows.append((route, width, "dialog", note))
- print(" DIALOG %s @%s %s" % (route, width, note))
- return rows
- def main():
- app = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-") \
- else os.path.join(harness.REPO, "builddir", "clanker-saddle")
- sys.argv = [a for a in sys.argv
- if a != app or sys.argv.index(a) == 0]
- widths = DEFAULT_WIDTHS
- routes = None
- json_out = os.environ.get("MOBILE_AUDIT_JSON")
- keep = False
- args = sys.argv[1:]
- i = 0
- while i < len(args):
- if args[i] == "--widths":
- widths = [int(w) for w in args[i + 1].split(",")]
- i += 2
- elif args[i] == "--routes":
- routes = args[i + 1].split(",")
- i += 2
- elif args[i] == "--json":
- json_out = args[i + 1]
- i += 2
- elif args[i] == "--keep":
- keep = True
- i += 1
- else:
- i += 1
- if not os.path.exists(app):
- raise SystemExit("app binary not found at %s (build first)" % app)
- findings = run_audit(app, widths, routes, json_out, keep)
- if findings is None:
- return 0
- rows = summarise(findings)
- overflow = [r for r in rows if r[2] == "overflow"]
- dialogs = [r for r in rows if r[2] == "dialog"]
- touch = [r for r in rows if r[2] == "touch"]
- print("\nSUMMARY: %d overflow findings, %d dialog-fit findings,"
- " %d sub-44px touch targets"
- % (len(overflow), len(dialogs), len(touch)))
- for row in overflow:
- print(" OVERFLOW %s @%s %s" % (row[0], row[1], row[3]))
- for row in dialogs:
- print(" DIALOG %s @%s %s" % (row[0], row[1], row[3]))
- for row in touch:
- print(" TOUCH %s @%s %s" % (row[0], row[1], row[3]))
- return 1 if (overflow or dialogs) else 0
- if __name__ == "__main__":
- sys.exit(main())
|