Files
LibreMail/scripts/device-testing/scenarios.py
T
JMR-devandClaude Opus 4.8 e436503eaf feat(scripts): device-testing perf harness
Add a cross-platform, standard-library-only Python package under
scripts/device-testing/ that replicates LibreMail's on-device performance-test
scenarios and logging capture, codifying the methodology run by hand on
2026-07-05 (Pixel 10 Pro XL).

Modules:
- breadcrumbs.py: a pure, unit-tested parser for the ImapPerf / MailReader /
  Reader / MailBackfiller breadcrumbs, plus open-correlation that reproduces the
  manual timing-tables.md figures exactly.
- adb.py: a safety-guarded adb wrapper -- an allow-list of adb subcommands and a
  deny-list + assertions on shell commands. The only sanctioned app-state
  mutation is clearing LibreMail's own cache/ (exact-match); no pm clear /
  uninstall / data wipe, and no touching databases/ files/ shared_prefs/
  datastore/ can be constructed.
- uidump.py: uiautomator XML parser + screen recognition (mailbox rows with the
  cached "Available offline" flag, reader, and the keyguard / foreign-app guards).
- scenarios.py: cold-open, message-open (uncached), back-nav, prefetch A/B
  (fetch-policy toggle) and cross-provider, each keyguard-guarded and driven
  through the guarded wrapper.
- report.py + perf_harness.py: aggregates, a timing-tables.md renderer mirroring
  the manual write-up, and the CLI (timestamped run dir with the raw logcat, a
  filtered breadcrumb extract, and the tables). --dry-run prints the exact
  command plan without touching device state.

Tests (stdlib unittest, 68 cases) validate the parser, guardrails, UI
recognition and report against the manual run's real captures (fixtures include
a verbatim perf-extract slice and the reader/lockscreen/alarm dumps). Track the
*.log fixture past the gitignore *.log rule via a scoped negation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:21:34 -05:00

446 lines
16 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""scenarios.py -- the on-device performance scenarios.
Each scenario drives LibreMail through the guarded :class:`adb.Adb` wrapper, times the
behaviour primarily from the on-device breadcrumbs (with uiautomator as a fallback ready
signal), and returns render-ready samples for :mod:`report`.
Timing philosophy (from the manual run): message-open and reader-ready times come from the
``MailReader`` / ``Reader`` / ``ImapPerf`` breadcrumbs -- adb UI polling only tells us *when*
the content is ready so we can move on, and back-nav is explicitly dump-latency-bound.
Safety: every device call goes through :class:`adb.Adb`, so its allow/deny guardrails apply.
Every uiautomator/input step is guarded against the keyguard and against a foreign app being
in the foreground; a sample taken against either is skipped, not measured.
``--dry-run`` executes one representative pass per scenario -- issuing the canonical command
sequence (so the plan is auditable) without looping on a live UI.
"""
from __future__ import annotations
import os
import time
from typing import Callable, List, Optional
import breadcrumbs
import uidump
from adb import Adb, parse_am_start
from report import BackNavSample, ColdOpenSample, ReaderOpenRow
# Message-open can stall 30-75 s behind the spinner (Gmail throttle), so allow generous
# headroom before giving up on a single open.
OPEN_TIMEOUT_S = 150.0
POLL_INTERVAL_S = 2.0
SETTLE_S = 1.5
# Fetch-policy option labels (from res/values/strings.xml) used to drive the A/B toggle.
FETCH_WIFI_LABEL = "Fetch all on Wi-Fi" # prefetch ON (Condition A / WIFI_ONLY)
FETCH_ON_DEMAND_LABEL = "Always on-demand" # prefetch OFF (Condition B / ON_DEMAND)
# --------------------------------------------------------------------------- #
# Live-logcat tailing (per-sample breadcrumb extraction)
# --------------------------------------------------------------------------- #
class LogTailer:
"""Reads newly-appended text from the growing session logcat file since the last mark."""
def __init__(self, path: str) -> None:
self.path = path
self._offset = 0
def mark(self) -> None:
"""Set the read cursor to the current end of file."""
self._offset = os.path.getsize(self.path) if os.path.exists(self.path) else 0
def read_new(self) -> str:
if not os.path.exists(self.path):
return ""
with open(self.path, "r", encoding="utf-8", errors="replace") as fh:
fh.seek(self._offset)
data = fh.read()
self._offset = fh.tell()
return data
class _NullTailer(LogTailer):
"""A tailer that yields nothing -- used in dry-run so scenarios need no live log."""
def __init__(self) -> None: # noqa: D401 - see base
super().__init__(path="")
def mark(self) -> None:
pass
def read_new(self) -> str:
return ""
# --------------------------------------------------------------------------- #
# UI helpers (all guarded)
# --------------------------------------------------------------------------- #
def dump_ui(adb: Adb) -> Optional[uidump.UiNode]:
"""Dump + parse the current UI; ``None`` in dry-run or on an unparseable dump."""
if adb.dry_run:
return None
xml = adb.uiautomator_dump()
if not xml or "<hierarchy" not in xml:
return None
try:
return uidump.parse_dump(xml)
except Exception: # pragma: no cover - malformed dump under load
return None
def ensure_awake(adb: Adb, log: Callable[[str], None]) -> None:
"""Keep the screen on and awake for the run (restored by the caller afterwards)."""
adb.stay_on(True)
adb.wake()
def guard_ready(adb: Adb, package: str, log: Callable[[str], None]) -> Optional[uidump.UiNode]:
"""Return the current UI iff it is our app and not the keyguard; else try to recover.
Returns ``None`` if, after a wake attempt, the sample is still against the lockscreen or
a foreign app -- the caller must skip that sample rather than measure garbage.
"""
root = dump_ui(adb)
if root is None:
return None
if uidump.is_lockscreen(root):
log("keyguard detected; waking and re-checking")
adb.wake()
time.sleep(SETTLE_S)
root = dump_ui(adb)
if root is None or uidump.is_lockscreen(root):
log("still on keyguard after wake; skipping sample")
return None
if not uidump.is_app_foreground(root, package):
log(f"foreground is {uidump.foreground_package(root)!r}, not {package!r}; skipping")
return None
return root
def goto_mailbox(adb: Adb, package: str, log: Callable[[str], None]) -> Optional[uidump.UiNode]:
"""Ensure the mailbox list is showing (press Back out of the reader if needed)."""
root = guard_ready(adb, package, log)
if root is None:
return None
if uidump.is_reader(root, package):
adb.input_keyevent("KEYCODE_BACK")
time.sleep(SETTLE_S)
root = guard_ready(adb, package, log)
return root
def wait_for_reader_ready(adb: Adb, package: str, log: Callable[[str], None]) -> bool:
"""Poll until the reader has loaded its body (no spinner) or timeout. True if loaded."""
deadline = time.monotonic() + OPEN_TIMEOUT_S
while time.monotonic() < deadline:
time.sleep(POLL_INTERVAL_S)
root = dump_ui(adb)
if root is None:
continue
if uidump.is_lockscreen(root):
log("keyguard appeared during open; sample is invalid")
return False
if uidump.is_reader(root, package) and not uidump.has_progress_bar(root):
return True
log("open timed out")
return False
# --------------------------------------------------------------------------- #
# Scenario 1: cold open
# --------------------------------------------------------------------------- #
def cold_open(
adb: Adb,
component: str,
runs: int,
log: Callable[[str], None],
settle_s: float = 2.0,
) -> List[ColdOpenSample]:
"""Force-stop + clear cache + ``am start -W`` x ``runs``; parse TotalTime/WaitTime."""
samples: List[ColdOpenSample] = []
for run in range(1, runs + 1):
adb.force_stop()
adb.clear_cache()
adb.settle(settle_s)
out = adb.start_activity(component=component, wait=True).stdout
fields = parse_am_start(out)
note = "" if fields else ("dry-run" if adb.dry_run else "no am-start timing parsed")
samples.append(
ColdOpenSample(
run=run,
total_time_ms=fields.get("TotalTime"),
wait_time_ms=fields.get("WaitTime"),
note=note,
)
)
log(f"cold-open run {run}: {fields or note}")
adb.settle(settle_s)
return samples
# --------------------------------------------------------------------------- #
# Scenario 2: message open (uncached)
# --------------------------------------------------------------------------- #
def _row_from_events(index: int, label: str, events: List[breadcrumbs.Event]) -> ReaderOpenRow:
"""Build a reader-open row from the breadcrumbs captured during one open."""
opens = breadcrumbs.correlate_opens(events)
if not opens:
return ReaderOpenRow(index=index, label=label, skipped=True, reason="no breadcrumb")
sample = opens[-1]
return ReaderOpenRow.from_open_sample(sample, index=index, label=label)
def open_one_message(
adb: Adb,
package: str,
tailer: LogTailer,
index: int,
already_opened: set,
log: Callable[[str], None],
) -> Optional[ReaderOpenRow]:
"""Open the next not-yet-opened, uncached message; return its timed row (or ``None``)."""
root = goto_mailbox(adb, package, log)
if root is None:
return ReaderOpenRow(index=index, label="?", skipped=True, reason="not on mailbox")
rows = uidump.find_message_rows(root, package)
candidates = [r for r in rows if not r.cached and r.label not in already_opened]
if not candidates:
# Scroll to reveal more of the list, then re-scan once.
adb.input_swipe(672, 2000, 672, 900, 400)
time.sleep(SETTLE_S)
root = guard_ready(adb, package, log)
if root is None:
return None
rows = uidump.find_message_rows(root, package)
candidates = [r for r in rows if not r.cached and r.label not in already_opened]
if not candidates:
log("no more uncached messages visible")
return None
target = candidates[0]
already_opened.add(target.label)
log(f"opening row#{target.index} {target.label!r} at {target.center}")
tailer.mark()
adb.input_tap(*target.center)
loaded = wait_for_reader_ready(adb, package, log)
events = list(breadcrumbs.iter_events(tailer.read_new().splitlines()))
row = _row_from_events(index, target.label, events)
if not loaded and not row.skipped:
row.reason = "ui ready-signal timed out (breadcrumb used)"
elif not loaded:
row.skipped = True
row.reason = "open timed out"
# Return to the mailbox for the next sample.
adb.input_keyevent("KEYCODE_BACK")
time.sleep(SETTLE_S)
return row
def message_open(
adb: Adb,
package: str,
tailer: LogTailer,
count: int,
log: Callable[[str], None],
) -> List[ReaderOpenRow]:
"""Open up to ``count`` distinct uncached messages one at a time."""
if adb.dry_run:
_dry_run_open_demo(adb, log)
return [ReaderOpenRow(index=1, label="<dry-run>", skipped=True, reason="dry-run")]
rows: List[ReaderOpenRow] = []
opened: set = set()
for i in range(1, count + 1):
row = open_one_message(adb, package, tailer, i, opened, log)
if row is None:
break
rows.append(row)
return rows
def _dry_run_open_demo(adb: Adb, log: Callable[[str], None]) -> None:
"""Issue the canonical message-open command shape once (dry-run only)."""
adb.uiautomator_dump()
adb.input_tap(672, 504) # tap a representative message row centre
adb.input_keyevent("KEYCODE_BACK")
# --------------------------------------------------------------------------- #
# Scenario 3: back navigation
# --------------------------------------------------------------------------- #
def back_nav(
adb: Adb,
package: str,
count: int,
log: Callable[[str], None],
) -> List[BackNavSample]:
"""Time reader -> mailbox back transitions (dump-latency-bound; see report caveat)."""
if adb.dry_run:
adb.uiautomator_dump()
adb.input_keyevent("KEYCODE_BACK")
return [BackNavSample(index=1, back_ms=None, note="dry-run")]
samples: List[BackNavSample] = []
opened: set = set()
for i in range(1, count + 1):
# Get into the reader by opening any message.
root = goto_mailbox(adb, package, log)
if root is None:
samples.append(BackNavSample(index=i, back_ms=None, note="not on mailbox"))
continue
rows = uidump.find_message_rows(root, package)
if not rows:
samples.append(BackNavSample(index=i, back_ms=None, note="no rows"))
continue
adb.input_tap(*rows[0].center)
wait_for_reader_ready(adb, package, log)
# Now time the back transition to the mailbox.
start = time.monotonic()
adb.input_keyevent("KEYCODE_BACK")
reached = _wait_until(
lambda: _is_mailbox(adb, package), timeout_s=30.0
)
elapsed_ms = round((time.monotonic() - start) * 1000)
samples.append(
BackNavSample(
index=i,
back_ms=elapsed_ms if reached else None,
note="" if reached else "did not reach mailbox",
)
)
return samples
def _is_mailbox(adb: Adb, package: str) -> bool:
root = dump_ui(adb)
return root is not None and uidump.is_app_foreground(root, package) and not uidump.is_reader(
root, package
)
def _wait_until(predicate: Callable[[], bool], timeout_s: float) -> bool:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.2)
return False
# --------------------------------------------------------------------------- #
# Scenario 4: prefetch A/B (fetch policy toggle)
# --------------------------------------------------------------------------- #
def set_fetch_policy(
adb: Adb,
package: str,
option_label: str,
log: Callable[[str], None],
) -> bool:
"""Navigate Settings and select a fetch-policy option by its visible label.
NOTE: no uiautomator dump of the settings screen was captured in the manual run, so the
settings-screen navigation is by on-screen text and needs live verification (documented
in the README). Returns True if the option was found and tapped.
"""
if adb.dry_run:
adb.uiautomator_dump()
adb.input_tap(1014, 2728) # bottom-nav "Settings"
adb.uiautomator_dump()
adb.input_tap(672, 1400) # representative fetch-policy option
adb.input_keyevent("KEYCODE_BACK")
return True
root = goto_mailbox(adb, package, log)
if root is None:
return False
settings_label = uidump.find_by_text(root, "Settings")
if settings_label is None:
log("no Settings entry found in bottom nav")
return False
anchor = settings_label.first_clickable_ancestor() or settings_label
if anchor.center:
adb.input_tap(*anchor.center)
time.sleep(SETTLE_S)
# Find the fetch-policy option, scrolling the settings list if necessary.
for _ in range(6):
sroot = guard_ready(adb, package, log)
if sroot is None:
return False
option = uidump.find_by_text(sroot, option_label)
if option is not None:
anchor = option.first_clickable_ancestor() or option
if anchor.center:
adb.input_tap(*anchor.center)
log(f"selected fetch policy {option_label!r}")
time.sleep(SETTLE_S)
adb.input_keyevent("KEYCODE_BACK") # back to mailbox
time.sleep(SETTLE_S)
return True
adb.input_swipe(672, 2000, 672, 900, 400)
time.sleep(SETTLE_S)
log(f"fetch-policy option {option_label!r} not found")
return False
def prefetch_ab(
adb: Adb,
package: str,
component: str,
tailer: LogTailer,
count: int,
log: Callable[[str], None],
) -> dict:
"""Run message-open under prefetch ON (Wi-Fi) vs OFF (on-demand), cache cleared between."""
result = {"conditionA": [], "conditionB": []}
log("Condition A: fetch policy = Fetch all on Wi-Fi (prefetch ON)")
set_fetch_policy(adb, package, FETCH_WIFI_LABEL, log)
_reset_for_uncached(adb, component, log)
result["conditionA"] = message_open(adb, package, tailer, count, log)
log("Condition B: fetch policy = Always on-demand (prefetch OFF)")
set_fetch_policy(adb, package, FETCH_ON_DEMAND_LABEL, log)
_reset_for_uncached(adb, component, log)
result["conditionB"] = message_open(adb, package, tailer, count, log)
return result
def _reset_for_uncached(adb: Adb, component: str, log: Callable[[str], None]) -> None:
"""Clear the cache (and restart) so subsequent opens are uncached network fetches."""
adb.force_stop()
adb.clear_cache()
adb.settle(1.0)
adb.start_activity(component=component, wait=True)
adb.settle(3.0)
# --------------------------------------------------------------------------- #
# Scenario 5: cross-provider (optional)
# --------------------------------------------------------------------------- #
def cross_provider(
adb: Adb,
package: str,
tailer: LogTailer,
count: int,
log: Callable[[str], None],
) -> dict:
"""Open ``count`` messages from the (unified) inbox and bucket the rows by account_ref.
The breadcrumbs carry the account reference (``imap:...`` vs ``outlook:...``), so a
single mixed-inbox pass yields the per-provider comparison without account switching.
"""
rows = message_open(adb, package, tailer, count, log)
buckets: dict = {}
for row in rows:
key = row.account_ref or "unknown"
buckets.setdefault(key, []).append(row)
return buckets