Files
LibreMail/.claude/skills/preflight/api37_e2e.py
T
JMR-dev 58447d7d12 ci(e2e): gate the emulator on window focus to fix the RootViewPicker flake (#468)
Root cause: intermittently the launched activity window has has-window-focus=false
for the WHOLE instrumented run, so Espresso's RootViewPicker (onView().check(),
Intents.intended(), pressBack(), focus-dependent clipboard) times out after 10s and
fails EVERY focus-dependent test at once while the ~280 pure-Compose semantics tests
(which don't need window focus) pass. A failing E2E (35) leg's logcat (PR #470, run
28985259521) shows has-window-focus=true ZERO times across the whole session and both
the first attempt and the once-retry fail identically -- a persistent environmental
state, not a per-test transient. The prior mitigation, a single fire-and-forget
`adb shell input keyevent 82` (MENU) right after boot, is too weak: MENU no longer
dismisses the modern (API 30+) keyguard and, delivered before SystemUI/keyguard comes
up, is simply dropped -- so the insecure keyguard / non-interactive display persists
and no app window ever takes focus.

Fix: a single shared helper, .github/scripts/emulator_focus_gate.py, invoked
identically by BOTH E2E jobs (the e2e API 29-36 matrix AND e2e-preview API 37) and by
the local preflight runners (local_instrumented.py / api37_e2e.py), so it cannot drift.
It wakes the display (KEYCODE_WAKEUP), dismisses + disables the keyguard
(wm dismiss-keyguard, locksettings set-disabled true), keeps the screen on
(svc power stayon true + max screen_off_timeout), zeroes the animation scales, then
polls dumpsys power/window until the device is interactive AND a real window holds
input focus (mCurrentFocus non-null) -- re-nudging each iteration -- before the suite
runs. Applied uniformly, this also gives e2e-preview the animation-disable the matrix
already had. The gate is soft (bounded wait, then proceeds with a ::warning:: and the
final device state) and non-fatal (`|| true`), preserving #454's guarantee that the
unlock never aborts the boot; it leaves #454's manual boot, #460's path-filter and
#464's wedge-capture untouched.

The pure readiness parser is unit-tested by test_emulator_focus_gate.py (run by the
traffic-control-tests job). Determinism is validated by this PR's own matrix run.

Closes #468
2026-07-08 20:32:16 -05:00

408 lines
18 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Hand-provision an API 37 (Android 17, preview) emulator, run LibreMail's instrumented/E2E
suite against it, then tear the emulator and AVD down. Invoked by the /preflight skill as the
third (api37) E2E step, alongside the api35/api36 Gradle Managed Devices.
WHY THIS SCRIPT EXISTS
----------------------
API 37's only published system image is the nonstandard "android-37.0" / google_apis_ps16k
(16 KB page size) pairing. AGP's Gradle Managed Device DSL can only build an
"android-<apiLevel:Int>" package id (apiLevel = 37 -> "android-37") or an
"android-<apiPreview:codename>" one -- neither resolves to "android-37.0" -- so there is NO
api37DebugAndroidTest task to run. This script custom-provisions the emulator with
sdkmanager / avdmanager / emulator directly, closely mirroring CI's `e2e-preview` job in
.github/workflows/ci.yml (same system image string, same provisioning/boot sequence, same
emulator flags EXCEPT the GPU mode -- CI uses `-gpu swiftshader_indirect` for headless
determinism, while this local run uses `-gpu auto-no-window` to render on the host GPU, which
is faster; see start_emulator). Keep the two in lockstep on everything but that GPU flag: when a
stable, GMD-compatible API 37 image ships, delete this script, fold api37 into
testOptions.managedDevices, and fold 37 into CI's `e2e` matrix (dropping the `e2e-preview` job).
Pure standard library, cross-platform (Windows / Linux / macOS): tool paths and executable
suffixes are resolved per-OS, and `.bat` launchers are wrapped through `cmd /c` on Windows.
HYPERVISOR REQUIREMENT
----------------------
The emulator boots with `-accel on`, so it needs a FREE hardware hypervisor (Intel VT-x / AMD-V,
exposed as WHPX on Windows, KVM on Linux, HVF on macOS). If VirtualBox, Hyper-V, WSL2, Docker
Desktop, or another emulator is holding it, `-accel on` fails or the AVD hangs at 0% CPU and never
reaches sys.boot_completed. Shut those down before running preflight.
JDK: the final Gradle step needs a JDK 17-21 daemon (AGP 9.2 fails on JDK 25+), same as the rest
of preflight -- point JAVA_HOME at a 17-21 JDK before invoking.
"""
from __future__ import annotations
import argparse
import os
import platform
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
# Defaults mirror .github/workflows/ci.yml e2e-preview (env.API37_IMAGE, env.ANDROID_PLATFORM,
# env.ANDROID_BUILD_TOOLS) and its avdmanager invocation. Do not diverge without updating ci.yml.
API37_IMAGE = "system-images;android-37.0;google_apis_ps16k;x86_64"
PLATFORM_PKG = "platforms;android-37.0"
BUILD_TOOLS = "build-tools;37.0.0"
AVD_NAME = "api37"
DEVICE_PROFILE = "pixel_2"
BOOT_TIMEOUT = 300
# GPU mode: the ONE deliberate divergence from CI's e2e-preview (which uses `swiftshader_indirect`
# for headless determinism). Locally we render on the host GPU -- faster, and the mode that boots
# cleanly on a dev machine. See start_emulator. Kept as a constant so start_emulator and the
# boot-failure diagnostics dump report the same value.
GPU_MODE = "auto-no-window"
IS_WINDOWS = os.name == "nt"
BAT = ".bat" if IS_WINDOWS else ""
EXE = ".exe" if IS_WINDOWS else ""
def cmd(tool: str, *args: str) -> list[str]:
"""Build an argv list, wrapping Windows `.bat`/`.cmd` launchers through `cmd /c`."""
if IS_WINDOWS and tool.lower().endswith((".bat", ".cmd")):
return ["cmd", "/c", tool, *args]
return [tool, *args]
def find_sdk_root() -> str:
candidates = [os.environ.get("ANDROID_SDK_ROOT"), os.environ.get("ANDROID_HOME")]
system = platform.system()
if system == "Windows":
local = os.environ.get("LOCALAPPDATA")
if local:
candidates.append(os.path.join(local, "Android", "Sdk"))
elif system == "Darwin":
candidates.append(os.path.expanduser("~/Library/Android/sdk"))
else:
candidates.append(os.path.expanduser("~/Android/Sdk"))
for candidate in candidates:
if candidate and os.path.isdir(candidate):
return os.path.abspath(candidate)
raise RuntimeError(
"Android SDK not found. Set ANDROID_SDK_ROOT (or ANDROID_HOME) to your SDK location."
)
def resolve_tool(sdk_root: str, rel_paths: list[list[str]], name: str) -> str:
for rel in rel_paths:
path = os.path.join(sdk_root, *rel)
if os.path.isfile(path):
return path
raise RuntimeError(
f"Could not find {name} under {sdk_root}. "
"Install the Android SDK command-line tools + emulator."
)
def run_sdkmanager(sdkmanager: str, args: list[str]) -> None:
# Feed a stream of "y" so any unaccepted (incl. preview) license prompt is auto-accepted; this
# is the non-interactive equivalent of the CI runner having licenses pre-accepted.
result = subprocess.run(cmd(sdkmanager, *args), input="y\n" * 50, text=True, check=False)
if result.returncode != 0:
raise RuntimeError(f"sdkmanager failed (exit {result.returncode}) for: {' '.join(args)}")
def create_avd(avdmanager: str, emulator: str) -> None:
print(f"Creating AVD '{AVD_NAME}' from {API37_IMAGE} (device: {DEVICE_PROFILE})...")
# "no" answers avdmanager's "create a custom hardware profile?" prompt, mirroring CI.
result = subprocess.run(
cmd(avdmanager, "create", "avd", "-n", AVD_NAME, "-k", API37_IMAGE,
"-d", DEVICE_PROFILE, "--force"),
input="no\n", text=True, check=False,
)
if result.returncode != 0:
raise RuntimeError(f"avdmanager create avd failed (exit {result.returncode}).")
print("AVDs visible to the emulator:")
subprocess.run(cmd(emulator, "-list-avds"), check=False)
def start_emulator(emulator: str, emu_log: Path, attempt: int) -> subprocess.Popen:
print(f"Starting API 37 emulator (attempt {attempt})...")
# Flags mirror .github/workflows/ci.yml e2e-preview (cold headless boot, hardware accel
# required, no cameras), with ONE deliberate LOCAL exception -- the GPU mode (GPU_MODE above:
# CI uses `-gpu swiftshader_indirect`, deterministic on a headless CI runner; locally we render
# on the host GPU -- faster, and the mode that boots cleanly on a dev machine). `-verbose -debug
# init,avd_config,kernel` turns emulator boot logging on by default (mirrors CI) so a boot flake
# is diagnosable from $EMU_LOG; it is DIAGNOSTICS ONLY and does not change any boot-affecting
# flag. Keep everything except the GPU mode in lockstep with that job.
flags = [
"-avd", AVD_NAME,
"-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", "-accel", "on",
"-gpu", GPU_MODE, "-camera-back", "none", "-camera-front", "none",
"-verbose", "-debug", "init,avd_config,kernel",
]
log = open(emu_log, "wb") # noqa: SIM115 - handed to the child; closed in the parent below
try:
proc = subprocess.Popen(cmd(emulator, *flags), stdout=log, stderr=subprocess.STDOUT)
finally:
log.close() # the child has inherited its own fd; the parent's copy is no longer needed
return proc
def wait_for_boot(adb: str, proc: subprocess.Popen, timeout: int) -> bool:
subprocess.run(cmd(adb, "start-server"), check=False)
deadline = time.monotonic() + timeout
# Phase 1 -- the literal `adb wait-for-device`, bounded so a dead emulator can't hang the run
# (returns as soon as the emulator registers). Mirrors CI's `adb wait-for-device`.
try:
subprocess.run(cmd(adb, "wait-for-device"), timeout=max(1, deadline - time.monotonic()),
check=False)
except subprocess.TimeoutExpired:
return False
if proc.poll() is not None:
return False
# Phase 2 -- poll sys.boot_completed until it flips to 1 (mirrors CI's getprop loop).
while time.monotonic() < deadline:
if proc.poll() is not None:
print(f"Emulator process exited during boot (code {proc.returncode}).",
file=sys.stderr)
return False
out = subprocess.run(cmd(adb, "shell", "getprop", "sys.boot_completed"),
capture_output=True, text=True, check=False)
if out.stdout.strip() == "1":
return True
time.sleep(2)
return False
def stop_emulator(adb: str | None, proc: subprocess.Popen | None) -> None:
if adb:
subprocess.run(cmd(adb, "emu", "kill"), check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(2)
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
def tail(path: Path, lines: int = 80) -> None:
try:
with open(path, "r", errors="replace") as handle:
content = handle.readlines()[-lines:]
print("--- emulator.log (tail) ---")
print("".join(content))
except OSError:
pass
def _accel_check(emulator: str) -> str:
"""`emulator -accel-check` output -- the accelerator status (WHPX / KVM / HVF availability)."""
try:
out = subprocess.run(cmd(emulator, "-accel-check"), capture_output=True, text=True,
check=False)
return (out.stdout + out.stderr).strip() or f"(no output; exit {out.returncode})"
except OSError as exc:
return f"(accel-check failed: {exc})"
def _kvm_status() -> str:
"""/dev/kvm presence (Linux). Off-Linux the accelerator is WHPX/HVF -- see -accel-check."""
if os.path.exists("/dev/kvm"):
return "/dev/kvm present"
return f"/dev/kvm absent (expected off-Linux; platform={platform.system()})"
def _mem_info() -> str:
"""Free/total memory. Reads /proc/meminfo on Linux (where CI runs); best-effort elsewhere."""
try:
meminfo = Path("/proc/meminfo")
if meminfo.exists():
wanted = {"MemTotal", "MemFree", "MemAvailable"}
lines = [line.strip() for line in meminfo.read_text().splitlines()
if line.split(":", 1)[0] in wanted]
if lines:
return "; ".join(lines)
except OSError:
pass
return f"(memory stats unavailable on {platform.system()})"
def _disk_info(path: Path) -> str:
"""Free/total disk for the filesystem holding `path` (cross-platform via shutil.disk_usage)."""
try:
usage = shutil.disk_usage(path)
gib = 1024 ** 3
return f"total={usage.total / gib:.1f}GiB free={usage.free / gib:.1f}GiB ({path})"
except OSError as exc:
return f"(disk stats unavailable: {exc})"
def start_logcat(adb: str, logcat_log: Path, attempt: int) -> subprocess.Popen | None:
"""Background `adb wait-for-device logcat -v time` to a file. wait-for-device blocks until the
device registers, so streaming starts the moment the emulator appears and captures the whole
boot. Mirrors CI's e2e-preview logcat capture; appended (with a header) per boot attempt."""
try:
with open(logcat_log, "a") as marker:
marker.write(f"===== logcat (attempt {attempt}) =====\n")
log = open(logcat_log, "ab") # noqa: SIM115 - child inherits fd; parent copy closed below
try:
return subprocess.Popen(cmd(adb, "wait-for-device", "logcat", "-v", "time"),
stdout=log, stderr=subprocess.STDOUT)
finally:
log.close() # the child has inherited its own fd; the parent's copy is no longer needed
except OSError as exc:
print(f"WARNING: could not start logcat capture: {exc}", file=sys.stderr)
return None
def stop_logcat(proc: subprocess.Popen | None) -> None:
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
def dump_diagnostics(adb: str, emulator: str, emu_log: Path, avd_home: Path, attempt: int) -> None:
"""Print boot diagnostics + a concise failure summary to the console -- the local mirror of CI's
e2e-preview boot-timeout dump (accel/KVM/GPU/mem/disk/AVD config + emulator.log tail). Local
runs PRINT these; CI uploads the same set as an artifact and prints only the concise summary."""
accel = _accel_check(emulator)
kvm = _kvm_status()
config_ini = avd_home / f"{AVD_NAME}.avd" / "config.ini"
print(f"===== API 37 boot diagnostics (attempt {attempt}) =====")
print("--- adb devices ---")
subprocess.run(cmd(adb, "devices"), check=False)
print(f"--- emulator -accel-check ---\n{accel}")
print(f"--- KVM/hypervisor ---\n{kvm}")
print(f"--- GPU mode ---\n{GPU_MODE}")
print(f"--- free memory ---\n{_mem_info()}")
print(f"--- free disk ---\n{_disk_info(Path(tempfile.gettempdir()))}")
print("--- AVD config.ini ---")
try:
print(config_ini.read_text(errors="replace"))
except OSError as exc:
print(f"(could not read {config_ini}: {exc})")
# Concise failure summary (mirrors CI): accel/KVM status + the last 50 lines of emulator.log.
print(f"----- BOOT FAILURE SUMMARY (attempt {attempt}) -----")
print(f"accel-check: {accel}")
print(f"kvm: {kvm}")
tail(emu_log, 50)
def main() -> int:
parser = argparse.ArgumentParser(
description="Hand-provision + run the API 37 preview E2E suite.")
parser.add_argument("--boot-timeout", type=int, default=BOOT_TIMEOUT,
help="Seconds to wait for the emulator to reach sys.boot_completed.")
args = parser.parse_args()
sdk_root = find_sdk_root()
print(f"Using Android SDK at: {sdk_root}")
sdkmanager = resolve_tool(sdk_root, [
["cmdline-tools", "latest", "bin", "sdkmanager" + BAT],
["cmdline-tools", "bin", "sdkmanager" + BAT],
["tools", "bin", "sdkmanager" + BAT],
], "sdkmanager")
avdmanager = resolve_tool(sdk_root, [
["cmdline-tools", "latest", "bin", "avdmanager" + BAT],
["cmdline-tools", "bin", "avdmanager" + BAT],
["tools", "bin", "avdmanager" + BAT],
], "avdmanager")
# Pin ANDROID_AVD_HOME so avdmanager (writes it) and the emulator (reads it) agree on the AVD
# dir -- the same fix CI's e2e-preview applies to avoid "Unknown AVD name [api37]".
avd_home = Path.home() / ".android" / "avd"
avd_home.mkdir(parents=True, exist_ok=True)
os.environ["ANDROID_AVD_HOME"] = str(avd_home)
emu_log = Path(tempfile.gettempdir()) / "libremail-api37-emulator.log"
logcat_log = Path(tempfile.gettempdir()) / "libremail-api37-logcat.txt"
try:
logcat_log.unlink() # start fresh; start_logcat appends (with a header) per attempt
except OSError:
pass
adb: str | None = None
proc: subprocess.Popen | None = None
logcat_proc: subprocess.Popen | None = None
test_exit = 1
try:
# 1. Install the SDK platform, build-tools, platform-tools, emulator, and preview image.
print(f"Installing SDK packages + API 37 preview system image ({API37_IMAGE})...")
run_sdkmanager(sdkmanager, ["--licenses"])
run_sdkmanager(sdkmanager, [PLATFORM_PKG, BUILD_TOOLS, "platform-tools", "emulator",
API37_IMAGE])
# adb + emulator are only guaranteed present after the install above.
adb = resolve_tool(sdk_root, [["platform-tools", "adb" + EXE]], "adb")
emulator = resolve_tool(sdk_root, [["emulator", "emulator" + EXE]], "emulator")
# 2. Create the AVD, mirroring CI.
create_avd(avdmanager, emulator)
# 3. Cold-boot headless, retrying once (mirrors CI's two-attempt boot loop). Diagnostics
# (logcat capture + a boot-timeout dump) are ADDITIVE -- the retry/boot-wait is unchanged.
booted = False
for attempt in (1, 2):
proc = start_emulator(emulator, emu_log, attempt)
# Capture logcat from device registration onward (mirrors CI); killed on failure.
logcat_proc = start_logcat(adb, logcat_log, attempt)
if wait_for_boot(adb, proc, args.boot_timeout):
booted = True
break
print(f"API 37 emulator did not boot within {args.boot_timeout}s (attempt {attempt}).",
file=sys.stderr)
dump_diagnostics(adb, emulator, emu_log, avd_home, attempt)
stop_logcat(logcat_proc)
logcat_proc = None
stop_emulator(adb, proc)
proc = None
time.sleep(5)
if not booted:
raise RuntimeError("API 37 preview emulator failed to boot after 2 attempts.")
# 4. Force the emulator to grant the app window focus, then GATE on it (the SAME shared
# helper CI's e2e / e2e-preview jobs invoke, issue #468), before running the suite: wake
# the display, dismiss + disable the keyguard, keep the screen on, disable animations, and
# wait for a focused window. Replaces the lone `input keyevent 82`. Best-effort: fall back
# to that legacy nudge if the shared helper is somehow missing.
repo_root = Path(__file__).resolve().parents[3]
focus_gate = repo_root / ".github" / "scripts" / "emulator_focus_gate.py"
if focus_gate.is_file():
subprocess.run([sys.executable, str(focus_gate), "--adb", adb], check=False)
else:
subprocess.run(cmd(adb, "shell", "input", "keyevent", "82"), check=False)
gradlew = repo_root / ("gradlew.bat" if IS_WINDOWS else "gradlew")
print(f"Running :app:connectedDebugAndroidTest against {AVD_NAME}...")
test_exit = subprocess.run(
cmd(str(gradlew), ":app:connectedDebugAndroidTest", "--stacktrace"),
cwd=str(repo_root), check=False,
).returncode
except Exception as exc: # noqa: BLE001 - top-level guard so teardown always runs
print(f"ERROR: {exc}", file=sys.stderr)
test_exit = 1
finally:
# 5. Always tear the emulator down and delete the AVD, even on failure.
print("Tearing down API 37 emulator and AVD...")
stop_logcat(logcat_proc)
stop_emulator(adb, proc)
subprocess.run(cmd(avdmanager, "delete", "avd", "-n", AVD_NAME), check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(f"(emulator boot log: {emu_log})")
print(f"(logcat: {logcat_log})")
if test_exit != 0:
print(f"api37 connectedDebugAndroidTest failed (exit {test_exit}).", file=sys.stderr)
return test_exit
print("api37 E2E passed.")
return 0
if __name__ == "__main__":
sys.exit(main())