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

254 lines
8.2 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""report.py -- aggregate scenario samples and render ``timing-tables.md``.
Pure and testable: given the samples a scenario collected, it produces the same
per-scenario markdown tables + aggregates as the hand-written ``timing-tables.md`` from the
manual run. No device or I/O dependency beyond writing the final file.
"""
from __future__ import annotations
import statistics
from dataclasses import dataclass
from typing import List, Optional, Sequence
from breadcrumbs import OpenSample
# The floor below which adb uiautomator-dump timings cannot resolve an in-app transition
# (see perf_summary.md: nav/back were dominated by the ~2.5-3 s dump latency).
UIAUTOMATOR_LATENCY_FLOOR_MS = 3000
# --------------------------------------------------------------------------- #
# Stats + markdown helpers
# --------------------------------------------------------------------------- #
@dataclass
class Aggregate:
n: int
mean: Optional[float]
median: Optional[float]
minimum: Optional[int]
maximum: Optional[int]
def aggregate(values: Sequence[float]) -> Aggregate:
"""Summarise a list of numbers; safe on an empty list."""
vals = [v for v in values if v is not None]
if not vals:
return Aggregate(0, None, None, None, None)
return Aggregate(
n=len(vals),
mean=statistics.fmean(vals),
median=statistics.median(vals),
minimum=min(vals),
maximum=max(vals),
)
def md_table(headers: Sequence[str], rows: Sequence[Sequence[object]]) -> str:
"""Render a GitHub-flavoured markdown table."""
head = "| " + " | ".join(str(h) for h in headers) + " |"
sep = "| " + " | ".join("---" for _ in headers) + " |"
body = [
"| " + " | ".join("" if c is None else str(c) for c in row) + " |" for row in rows
]
return "\n".join([head, sep, *body])
def _ms(value: Optional[float]) -> str:
return "" if value is None else f"{round(value)}"
# --------------------------------------------------------------------------- #
# Cold open
# --------------------------------------------------------------------------- #
@dataclass
class ColdOpenSample:
run: int
total_time_ms: Optional[int]
wait_time_ms: Optional[int]
note: str = ""
def render_cold_open(samples: Sequence[ColdOpenSample]) -> str:
rows: List[Sequence[object]] = []
for s in samples:
rows.append([s.run, _ms(s.total_time_ms), _ms(s.wait_time_ms), s.note])
total_agg = aggregate([s.total_time_ms for s in samples if s.total_time_ms is not None])
wait_agg = aggregate([s.wait_time_ms for s in samples if s.wait_time_ms is not None])
rows.append(
[
"**mean**",
f"**{_ms(total_agg.mean)}**",
f"**{_ms(wait_agg.mean)}**",
f"median {_ms(total_agg.median)} / {_ms(wait_agg.median)}",
]
)
table = md_table(["Run", "TotalTime (ms)", "WaitTime (ms)", "note"], rows)
return (
"## Cold open (am start -W, cache cleared each run)\n\n"
+ table
+ "\n\nColdest run is the first post-clear launch (class-load/JIT); steady state is lower.\n"
)
# --------------------------------------------------------------------------- #
# Message open (reader body-load)
# --------------------------------------------------------------------------- #
@dataclass
class ReaderOpenRow:
"""A flattened, render-ready reader-open row (mirrors a timing-tables.md line)."""
index: int
label: str
account_ref: str = ""
cached: bool = False
took_ms: Optional[int] = None
reader_ready_ms: Optional[int] = None
connect_ms: Optional[int] = None
work_ms: Optional[int] = None
select_ms: Optional[int] = None
body_ms: Optional[int] = None
flag_ms: Optional[int] = None
live: Optional[int] = None
rfc822_bytes: Optional[int] = None
body_kb_per_s: Optional[float] = None
skipped: bool = False
reason: str = ""
@classmethod
def from_open_sample(
cls, sample: OpenSample, index: int, label: str
) -> "ReaderOpenRow":
bf = sample.body_fetch
op = sample.body_fetch_op
return cls(
index=index,
label=label,
account_ref=sample.account_ref,
cached=sample.cached,
took_ms=sample.took_ms,
reader_ready_ms=sample.reader_ready.took_ms if sample.reader_ready else None,
connect_ms=op.connect_ms if op else None,
work_ms=op.work_ms if op else None,
select_ms=bf.select_ms if bf else None,
body_ms=bf.body_ms if bf else None,
flag_ms=bf.flag_ms if bf else None,
live=op.live if op else None,
rfc822_bytes=bf.rfc822_bytes if bf else None,
body_kb_per_s=sample.body_kb_per_s,
)
_OPEN_HEADERS = [
"#",
"message",
"cached",
"rfc822 B",
"openMessage took",
"reader ready",
"connect",
"work",
"select",
"body-dl",
"flag",
"live",
"body KB/s",
]
def _open_row_cells(row: ReaderOpenRow) -> Sequence[object]:
if row.skipped:
return [
row.index,
row.label,
"-",
f"SKIPPED: {row.reason}",
"",
"",
"",
"",
"",
"",
"",
"",
"",
]
kbps = "" if row.body_kb_per_s is None else f"{row.body_kb_per_s:.1f}"
return [
row.index,
row.label,
"yes" if row.cached else "no",
row.rfc822_bytes if row.rfc822_bytes is not None else "",
_fmt_ms(row.took_ms),
_fmt_ms(row.reader_ready_ms),
_ms(row.connect_ms),
_ms(row.work_ms),
_ms(row.select_ms),
_ms(row.body_ms),
_ms(row.flag_ms),
_ms(row.live),
kbps,
]
def _fmt_ms(value: Optional[int]) -> str:
return "" if value is None else f"{value} ms"
def render_message_open(title: str, rows: Sequence[ReaderOpenRow]) -> str:
table = md_table(_OPEN_HEADERS, [_open_row_cells(r) for r in rows])
uncached = [r for r in rows if not r.skipped and not r.cached and r.took_ms is not None]
agg = aggregate([r.took_ms for r in uncached])
lines = [f"## {title}", "", table, ""]
if agg.n:
lines.append(
f"Uncached opens: n={agg.n}, openMessage median "
f"{_ms(agg.median)} ms, mean {_ms(agg.mean)} ms, "
f"range {agg.minimum}-{agg.maximum} ms."
)
skipped = [r for r in rows if r.skipped]
if skipped:
lines.append(f"Skipped samples: {len(skipped)} (see rows above).")
return "\n".join(lines) + "\n"
# --------------------------------------------------------------------------- #
# Back navigation
# --------------------------------------------------------------------------- #
@dataclass
class BackNavSample:
index: int
back_ms: Optional[int]
note: str = ""
def render_back_nav(samples: Sequence[BackNavSample]) -> str:
rows = [[s.index, _fmt_ms(s.back_ms), s.note] for s in samples]
agg = aggregate([s.back_ms for s in samples if s.back_ms is not None])
table = md_table(["#", "back (reader->mailbox)", "note"], rows)
caveat = (
f"\n\n**Caveat:** these are dominated by the ~{UIAUTOMATOR_LATENCY_FLOOR_MS} ms "
"uiautomator-dump latency floor; true in-app back is sub-second and not precisely "
"measurable via adb UI polling under load (see perf_summary.md)."
)
summary = "" if not agg.n else f"\n\nBack: n={agg.n}, mean {_ms(agg.mean)} ms, median {_ms(agg.median)} ms."
return "## Reader -> mailbox (back)\n\n" + table + summary + caveat + "\n"
# --------------------------------------------------------------------------- #
# Document assembly
# --------------------------------------------------------------------------- #
def render_header(metadata: dict) -> str:
lines = ["# LibreMail device perf run", ""]
for key, value in metadata.items():
lines.append(f"- **{key}:** {value}")
lines.append("")
return "\n".join(lines)
def build_document(metadata: dict, sections: Sequence[str]) -> str:
parts = [render_header(metadata), *sections]
return "\n".join(parts).rstrip() + "\n"