refactor(scripts): fold cold-fetch pause-hook A/B into device-testing harness (#405)

Bakes the proven 2026-07-06 cold-vs-warm pause-hook flow into
scripts/device-testing/ as a first-class, reproducible `cold-fetch-ab`
scenario, upstreaming the scratchpad driver.

- fetchgate.py: FETCH_GATE pause/resume/query helpers through the guarded
  adb wrapper, with ordered-broadcast read-back parsing (paused=[...]).
- scenarios.cold_fetch_ab: pre-arm halt -> detect sign-in (sync all
  breadcrumb) -> confirm halt (prefetch skipped) -> wait for header sync ->
  measure cold opens -> resume -> measure warm opens. ALWAYS resumes on exit
  (finally), even on error -- never leaves fetch paused.
- report.render_cold_fetch_ab: gate summary, cold/warm tables, cold-vs-warm
  delta, connect=0ms reuse proof, throttle signature.
- Portability (subsumes #392): file-based uiautomator dump (not /dev/tty),
  UTF-8 adb decode + PYTHONUTF8/console I/O, openMessage-breadcrumb readiness,
  row-selection hardening (skip non-message rows).

The pause hook is debug-build-only (#393/#395), so the scenario needs a debug
APK. Automated validation: mocked unittest coverage (adb/breadcrumbs/gate) for
the helpers and the A/B scenario incl. restore-on-error, plus a --dry-run path
exercised end-to-end through perf_harness.main. A full on-device run is a
follow-up. Dev-tooling only; no app/src changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 05:07:11 -05:00
co-authored by Claude Opus 4.8
parent 582d1f3077
commit 55f1f59e3d
10 changed files with 1310 additions and 8 deletions
+8
View File
@@ -203,6 +203,11 @@ def find_message_rows(root: UiNode, package: str) -> List[MessageRow]:
``ui_mailbox.xml``). A row carrying the ``"Available offline"`` content-desc has its
body cached already, so it is marked ``cached`` (callers pick uncached rows for the
uncached-open scenarios).
Row-selection hardening: a candidate is skipped unless it carries at least one multi-char
text label. A real message row always shows a sender/subject; a bare tappable container
(a stray clickable spacer, a "load more"/footer affordance, an empty section row) has
none, and tapping it would open nothing and corrupt a sample -- so it is not returned.
"""
scrollables = root.find_all(lambda n: n.scrollable and n.package == package)
rows: List[MessageRow] = []
@@ -217,6 +222,9 @@ def find_message_rows(root: UiNode, package: str) -> List[MessageRow]:
texts = child.descendant_texts()
# Drop single-letter avatar monograms; keep sender/subject/time.
texts = [t for t in texts if len(t) > 1]
if not texts:
# No sender/subject label -> not a message row; skip it (hardening).
continue
cached = any(d == "Available offline" for d in child.descendant_descs())
rows.append(
MessageRow(