Files
LibreMail/scripts/device-testing/breadcrumbs.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

423 lines
14 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""breadcrumbs.py -- pure parser for LibreMail on-device perf breadcrumbs.
This module is the *core* of the device-testing harness and is intentionally free of any
I/O, ``adb``, or device dependency so it can be unit-tested against the saved logcat
captures from the manual perf run (see ``tests/test_breadcrumbs.py`` and
``tests/fixtures/perf-extract-sample.log``).
It parses two things:
1. A raw ``adb logcat -v threadtime`` line into its fields (:func:`parse_logcat_line`).
2. The message payload of the four perf tags into typed events
(:func:`parse_breadcrumb` and the per-tag helpers).
Breadcrumb formats (verbatim from the app source, all PII-free):
* ``ImapPerf: <op> connect=<ms>ms work=<ms>ms live=<N>``
-- ``ImapClient`` generic per-operation timing; ``op`` is a short label
(``prefetch-body``, ``backfill-page``, ``body-fetch``, ``imap`` ...).
* ``ImapPerf: body-fetch select=<ms>ms body=<ms>ms flag=<ms>ms rfc822=<n>B chars=<n> att=<n>``
-- ``ImapClient`` detailed body-fetch phase split (emitted just before the generic
``body-fetch connect=.. work=.. live=..`` line for the same fetch).
* ``MailReader: openMessage <acctRef> folder=<label> fetchedBody=<bool> took=<ms>ms``
-- ``MailRepositoryImpl`` reader-open; ``fetchedBody=true`` means a real network body
fetch happened (i.e. the message was *not* cached).
* ``Reader: reader ready took=<ms>ms html=<bool> inline=<n>``
-- ``ReaderViewModel`` spinner-to-content time.
* ``MailBackfiller: backfill <acctRef> folder=<label> pages=<n> complete=<bool>``
* ``MailBackfiller: backfill slice: maxBatches=<n>``
* ``MailBackfiller: backfill slice done: moreWork=<bool>``
The threadtime tag column is space-padded by logcat (e.g. ``Reader :``); the parser
tolerates that and reports the trimmed tag (``Reader``).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Iterable, Iterator, List, Optional, Union
# Tags that make up the filtered perf extract (mirrors the manual
# `grep -E 'ImapPerf|MailReader|Reader|MailBackfiller'`). Matched on the *tag* column so a
# stray mention of one of these words inside another tag's message is not miscounted.
PERF_TAGS = ("ImapPerf", "MailReader", "Reader", "MailBackfiller")
# --------------------------------------------------------------------------- #
# Raw logcat line (``-v threadtime``)
# --------------------------------------------------------------------------- #
_THREADTIME_RE = re.compile(
r"^(?P<date>\d{2}-\d{2})\s+"
r"(?P<time>\d{2}:\d{2}:\d{2}\.\d{3})\s+"
r"(?P<pid>\d+)\s+"
r"(?P<tid>\d+)\s+"
r"(?P<level>[VDIWEFS])\s+"
r"(?P<tag>[^:]+?)\s*:\s?"
r"(?P<message>.*)$"
)
@dataclass(frozen=True)
class LogLine:
"""A single ``adb logcat -v threadtime`` line, split into fields."""
date: str
time: str
pid: int
tid: int
level: str
tag: str
message: str
raw: str
@property
def timestamp(self) -> str:
"""`MM-DD HH:MM:SS.mmm` -- the wall-clock stamp (no year in logcat)."""
return f"{self.date} {self.time}"
def parse_logcat_line(line: str) -> Optional[LogLine]:
"""Parse one threadtime logcat line; return ``None`` if it is not one."""
m = _THREADTIME_RE.match(line.rstrip("\r\n"))
if not m:
return None
return LogLine(
date=m.group("date"),
time=m.group("time"),
pid=int(m.group("pid")),
tid=int(m.group("tid")),
level=m.group("level"),
tag=m.group("tag").strip(),
message=m.group("message").strip(),
raw=line.rstrip("\r\n"),
)
def is_perf_line(line: str) -> bool:
"""True if ``line`` is a threadtime line whose tag is one of :data:`PERF_TAGS`."""
parsed = parse_logcat_line(line)
return parsed is not None and parsed.tag in PERF_TAGS
# --------------------------------------------------------------------------- #
# Typed breadcrumb events
# --------------------------------------------------------------------------- #
class Breadcrumb:
"""Base type: every event optionally carries its source :class:`LogLine`.
A plain (non-dataclass) base so each subclass can declare its own fields with ``line``
last -- this keeps the generated ``__init__`` valid on Python 3.7+ without the
3.10-only ``kw_only`` field option (a base dataclass field with a default would force a
"non-default argument follows default argument" error in the subclasses).
"""
line: Optional[LogLine] = None
@property
def timestamp(self) -> Optional[str]:
return self.line.timestamp if self.line else None
@dataclass
class ImapPerfOp(Breadcrumb):
"""``ImapPerf: <op> connect=<ms>ms work=<ms>ms live=<N>``."""
op: str
connect_ms: int
work_ms: int
live: int
line: Optional[LogLine] = None
@dataclass
class BodyFetch(Breadcrumb):
"""``ImapPerf: body-fetch select=.. body=.. flag=.. rfc822=..B chars=.. att=..``."""
select_ms: int
body_ms: int
flag_ms: int
rfc822_bytes: int
chars: int
att: int
line: Optional[LogLine] = None
@property
def body_kb_per_s(self) -> Optional[float]:
"""Body-download throughput in decimal KB/s; ``None`` if body_ms==0.
``bytes/ms`` is already KB/s (``bytes/ms * 1000 ms/s / 1000 B/KB``), so this mirrors
the manual ``timing-tables.md`` "body KB/s" column exactly (e.g. 60457/14253 = 4.2).
"""
if self.body_ms <= 0:
return None
return self.rfc822_bytes / self.body_ms
@dataclass
class OpenMessage(Breadcrumb):
"""``MailReader: openMessage <ref> folder=<label> fetchedBody=<bool> took=<ms>ms``."""
account_ref: str
folder: str
fetched_body: bool
took_ms: int
line: Optional[LogLine] = None
@dataclass
class ReaderReady(Breadcrumb):
"""``Reader: reader ready took=<ms>ms html=<bool> inline=<n>``."""
took_ms: int
html: bool
inline: int
line: Optional[LogLine] = None
@dataclass
class BackfillProgress(Breadcrumb):
"""``MailBackfiller: backfill <ref> folder=<label> pages=<n> complete=<bool>``."""
account_ref: str
folder: str
pages: int
complete: bool
line: Optional[LogLine] = None
@dataclass
class BackfillSliceStart(Breadcrumb):
"""``MailBackfiller: backfill slice: maxBatches=<n>``."""
max_batches: int
line: Optional[LogLine] = None
@dataclass
class BackfillSliceDone(Breadcrumb):
"""``MailBackfiller: backfill slice done: moreWork=<bool>``."""
more_work: bool
line: Optional[LogLine] = None
Event = Union[
ImapPerfOp,
BodyFetch,
OpenMessage,
ReaderReady,
BackfillProgress,
BackfillSliceStart,
BackfillSliceDone,
]
# --------------------------------------------------------------------------- #
# Message-payload parsers (pure; operate on the trimmed message string)
# --------------------------------------------------------------------------- #
def _to_bool(text: str) -> bool:
return text == "true"
_IMAP_OP_RE = re.compile(
r"^(?P<op>\S+)\s+connect=(?P<connect>\d+)ms\s+work=(?P<work>\d+)ms\s+live=(?P<live>\d+)$"
)
_BODY_FETCH_RE = re.compile(
r"^body-fetch\s+select=(?P<select>\d+)ms\s+body=(?P<body>\d+)ms\s+flag=(?P<flag>\d+)ms\s+"
r"rfc822=(?P<bytes>\d+)B\s+chars=(?P<chars>\d+)\s+att=(?P<att>\d+)$"
)
_OPEN_MSG_RE = re.compile(
r"^openMessage\s+(?P<ref>\S+)\s+folder=(?P<folder>\S+)\s+"
r"fetchedBody=(?P<fetched>true|false)\s+took=(?P<took>\d+)ms$"
)
_READER_READY_RE = re.compile(
r"^reader ready took=(?P<took>\d+)ms\s+html=(?P<html>true|false)\s+inline=(?P<inline>\d+)$"
)
_BACKFILL_PROGRESS_RE = re.compile(
r"^backfill\s+(?P<ref>\S+)\s+folder=(?P<folder>\S+)\s+pages=(?P<pages>\d+)\s+"
r"complete=(?P<complete>true|false)$"
)
_BACKFILL_SLICE_START_RE = re.compile(r"^backfill slice:\s+maxBatches=(?P<n>\d+)$")
_BACKFILL_SLICE_DONE_RE = re.compile(r"^backfill slice done:\s+moreWork=(?P<more>true|false)$")
def parse_imap_perf(message: str) -> Optional[Union[ImapPerfOp, BodyFetch]]:
"""Parse an ``ImapPerf`` message (either the detailed body-fetch or the generic op)."""
m = _BODY_FETCH_RE.match(message)
if m:
return BodyFetch(
select_ms=int(m.group("select")),
body_ms=int(m.group("body")),
flag_ms=int(m.group("flag")),
rfc822_bytes=int(m.group("bytes")),
chars=int(m.group("chars")),
att=int(m.group("att")),
)
m = _IMAP_OP_RE.match(message)
if m:
return ImapPerfOp(
op=m.group("op"),
connect_ms=int(m.group("connect")),
work_ms=int(m.group("work")),
live=int(m.group("live")),
)
return None
def parse_open_message(message: str) -> Optional[OpenMessage]:
"""Parse a ``MailReader`` ``openMessage`` message."""
m = _OPEN_MSG_RE.match(message)
if not m:
return None
return OpenMessage(
account_ref=m.group("ref"),
folder=m.group("folder"),
fetched_body=_to_bool(m.group("fetched")),
took_ms=int(m.group("took")),
)
def parse_reader_ready(message: str) -> Optional[ReaderReady]:
"""Parse a ``Reader`` ``reader ready`` message."""
m = _READER_READY_RE.match(message)
if not m:
return None
return ReaderReady(
took_ms=int(m.group("took")),
html=_to_bool(m.group("html")),
inline=int(m.group("inline")),
)
def parse_backfill(
message: str,
) -> Optional[Union[BackfillProgress, BackfillSliceStart, BackfillSliceDone]]:
"""Parse any of the three ``MailBackfiller`` messages."""
m = _BACKFILL_PROGRESS_RE.match(message)
if m:
return BackfillProgress(
account_ref=m.group("ref"),
folder=m.group("folder"),
pages=int(m.group("pages")),
complete=_to_bool(m.group("complete")),
)
m = _BACKFILL_SLICE_START_RE.match(message)
if m:
return BackfillSliceStart(max_batches=int(m.group("n")))
m = _BACKFILL_SLICE_DONE_RE.match(message)
if m:
return BackfillSliceDone(more_work=_to_bool(m.group("more")))
return None
# Dispatch table keyed by the trimmed logcat tag.
_TAG_PARSERS = {
"ImapPerf": parse_imap_perf,
"MailReader": parse_open_message,
"Reader": parse_reader_ready,
"MailBackfiller": parse_backfill,
}
def parse_breadcrumb(line: str) -> Optional[Event]:
"""Parse a full threadtime logcat line into a typed :data:`Event` (or ``None``).
The returned event carries its source :class:`LogLine` on ``.line`` so callers can
order and time-box events. Lines that are not perf breadcrumbs return ``None``.
"""
log = parse_logcat_line(line)
if log is None:
return None
parser = _TAG_PARSERS.get(log.tag)
if parser is None:
return None
event = parser(log.message)
if event is None:
return None
event.line = log
return event
def iter_events(lines: Iterable[str]) -> Iterator[Event]:
"""Yield every parseable breadcrumb :data:`Event` from an iterable of logcat lines."""
for line in lines:
event = parse_breadcrumb(line)
if event is not None:
yield event
# --------------------------------------------------------------------------- #
# Correlation -- group the breadcrumbs of one message-open together
# --------------------------------------------------------------------------- #
@dataclass
class OpenSample:
"""One reader-open, correlating the breadcrumbs the app emits for a single open.
Mirrors a row of the manual ``timing-tables.md``: the ``openMessage`` marker plus the
``body-fetch`` detail/op that preceded it and the ``reader ready`` that followed.
"""
open_message: OpenMessage
body_fetch: Optional[BodyFetch] = None
body_fetch_op: Optional[ImapPerfOp] = None
reader_ready: Optional[ReaderReady] = None
@property
def account_ref(self) -> str:
return self.open_message.account_ref
@property
def cached(self) -> bool:
"""A cached open did not fetch the body over the network."""
return not self.open_message.fetched_body
@property
def took_ms(self) -> int:
return self.open_message.took_ms
@property
def rfc822_bytes(self) -> Optional[int]:
return self.body_fetch.rfc822_bytes if self.body_fetch else None
@property
def body_kb_per_s(self) -> Optional[float]:
return self.body_fetch.body_kb_per_s if self.body_fetch else None
def correlate_opens(events: Iterable[Event]) -> List[OpenSample]:
"""Group a flat, time-ordered event stream into :class:`OpenSample` records.
Strategy (matches how the breadcrumbs interleave in a real capture): keep the most
recent ``body-fetch`` detail and generic ``body-fetch`` op seen; when an
``openMessage`` marker arrives, attach those (consuming them so they are not reused);
the first ``reader ready`` after the marker attaches to it.
"""
samples: List[OpenSample] = []
pending_detail: Optional[BodyFetch] = None
pending_op: Optional[ImapPerfOp] = None
open_awaiting_reader: Optional[OpenSample] = None
for event in events:
if isinstance(event, BodyFetch):
pending_detail = event
elif isinstance(event, ImapPerfOp) and event.op == "body-fetch":
pending_op = event
elif isinstance(event, OpenMessage):
sample = OpenSample(open_message=event)
if event.fetched_body:
sample.body_fetch = pending_detail
sample.body_fetch_op = pending_op
pending_detail = None
pending_op = None
samples.append(sample)
open_awaiting_reader = sample
elif isinstance(event, ReaderReady):
if open_awaiting_reader is not None and open_awaiting_reader.reader_ready is None:
open_awaiting_reader.reader_ready = event
open_awaiting_reader = None
return samples