Files
LibreMail/.claude/skills/preflight/local_instrumented.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

459 lines
20 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""local_instrumented.py -- reliable LOCAL instrumented / E2E test runner for LibreMail.
Usage: local_instrumented.py <fully.qualified.TestClass>[,<Class2>,...]
Example:
python .claude/skills/preflight/local_instrumented.py \
org.libremail.ui.compose.ComposeScreenE2ETest
python .claude/skills/preflight/local_instrumented.py \
org.libremail.ui.compose.ComposeScreenE2ETest,org.libremail.ui.compose.RecipientChipTest
Cross-platform (Windows / Linux / macOS), pure standard library. Companion to
``api37_e2e.py``; ported from the original ``local_instrumented.sh`` (issue #281) so the
helper runs the same on the Windows primary dev box and on *nix -- no Git Bash, no ``jq``,
no ``taskkill`` vs ``kill`` portability gaps.
WHY THIS SCRIPT EXISTS (issue #269)
------------------------------------
On this machine (Windows + the AEHD 2.2 hypervisor) the Gradle Managed Device (GMD)
instrumented tasks -- ``apiXXDebugAndroidTest`` -- FAIL during setup. GMD tries to
save/load an AVD *snapshot* and AEHD 2.2 cannot complete it:
AvdSnapshotHandler$EmulatorSnapshotCannotCreatedException: Snapshot creation timed out
GMD retries the snapshot ~5x, rebooting the AVD each time -- that endless reboot is the
"cycling" that eats hours. The emulator ITSELF is healthy (8 GB RAM, sys.boot_completed=1,
shell-responsive); only GMD's snapshot step is broken. So every LOCAL GMD task is affected:
the coverage lanes and the /preflight api35/api36 steps. CI is unaffected -- it uses
reactivecircus/android-emulator-runner + ``connectedDebugAndroidTest``, never GMD.
THE RELIABLE LOCAL PATH (this script):
Cold-boot ONE emulator by hand with ``-no-snapshot`` (no GMD, no snapshot machinery),
then run ``:app:connectedDebugAndroidTest`` -- the exact technique CI and ``api37_e2e.py``
already use. We reuse a GMD-provisioned AVD by name so we don't re-download a system
image; GMD re-provisions its own copy on its next run, so the ``-wipe-data`` cold boot
here does not disturb it.
KEEP RUNS TARGETED -- THE ~114-TEST MID-SUITE WEDGE
---------------------------------------------------
Running the WHOLE instrumented suite (~114 tests) via ``connectedDebugAndroidTest`` on this
box tends to wedge partway through -- the emulator stops making progress mid-run. Small,
targeted class sets do NOT hit that wedge. That is why this helper takes an explicit
``<fully.qualified.TestClass>[,...]`` argument and filters the run with
``-Pandroid.testInstrumentationRunnerArguments.class=...`` instead of running everything.
Run the class(es) you actually changed; do not use this to run the full suite (that is
CI's / preflight's job across the API matrix).
FREEZE / HYGIENE RATIONALE -- WHY THE ORPHAN-KILL + TEARDOWN VERIFY ARE MANDATORY
--------------------------------------------------------------------------------
A hung ``adb emu kill`` (or an interrupted run) leaves a detached qemu VM process behind
(``qemu-system-x86_64-headless.exe`` on Windows; a ``qemu-system-*`` process on *nix).
These orphans do not show up in ``adb devices``, they keep holding the hypervisor + RAM,
and accumulated orphans have FROZEN this machine outright. So this script:
* PREAMBLE -- force-kills any pre-existing qemu/emulator processes and resets the adb
server BEFORE booting, so we always start from a clean slate.
* TEARDOWN -- ``adb emu kill``, kill the launcher we spawned, then re-check for ANY
surviving emulator/qemu process and force-kill it (the ``-no-window`` emulator
can leave a sibling ``emulator.exe`` that briefly outlives the qemu VM). Teardown
runs even on Ctrl-C / error / SIGTERM (try/finally + atexit + SIGINT/SIGTERM
handlers) and is idempotent.
* VERIFY -- if a qemu process is STILL alive after the force-kill, the script exits
non-zero (code 3) so the leak is never silently ignored.
Never leave an emulator running after this script; if it exits 3, hunt the zombie down by
hand (Windows: ``tasklist | findstr qemu`` then ``taskkill /F /IM
qemu-system-x86_64-headless.exe``; *nix: ``pgrep -fa qemu-system`` then ``pkill -f
qemu-system``).
CROSS-PLATFORM PROCESS KILL
---------------------------
Listing and force-killing the emulator/qemu processes is abstracted per-OS (see
``list_procs`` / ``force_kill``): Windows uses ``tasklist`` + ``taskkill /F /IM <image>``;
*nix uses ``ps ax`` + ``pkill -f qemu-system`` (alongside the graceful ``adb emu kill``).
The qemu VM is the freeze-causing orphan on every platform.
EXIT CODES (preserved from local_instrumented.sh)
0 tests passed
2 usage / precondition failure
3 a qemu zombie survived teardown -- clean it up by hand before the next run
4 emulator never reached sys.boot_completed
<n> connectedDebugAndroidTest's own non-zero exit code (test failures)
REQUIREMENTS
* Android SDK ``emulator`` + ``adb`` on PATH.
* A JDK 17-21 for the Gradle daemon -- AGP 9.2 fails on JDK 25+. This script pins
JAVA_HOME to a known JDK 21 (override with LOCAL_INSTRUMENTED_JDK) because the ambient
JAVA_HOME on the primary box points at JDK 25.
* A free hardware hypervisor (VT-x/WHPX/AEHD/KVM/HVF). Shut down VirtualBox / other VMs
first or the AVD hangs at 0% CPU and never reaches sys.boot_completed.
Overridable via environment (defaults target the primary Windows dev box):
LOCAL_INSTRUMENTED_AVD AVD name to boot (dev36_google_apis_x86_64_Pixel_2)
ANDROID_AVD_HOME AVD home dir (C:/Users/jasonross/.android/avd/gradle-managed)
LOCAL_INSTRUMENTED_JDK JDK 17-21 home (Eclipse Adoptium jdk-21.0.11.10-hotspot)
LOCAL_INSTRUMENTED_SERIAL adb serial (emulator-5554)
LOCAL_INSTRUMENTED_BOOT_TIMEOUT boot wait seconds (300)
"""
from __future__ import annotations
import argparse
import atexit
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
from pathlib import Path
IS_WINDOWS = os.name == "nt"
# ---- configuration (env-overridable; defaults are correct for the primary dev box) ------
AVD_NAME = os.environ.get("LOCAL_INSTRUMENTED_AVD", "dev36_google_apis_x86_64_Pixel_2")
AVD_HOME = os.environ.get("ANDROID_AVD_HOME", "C:/Users/jasonross/.android/avd/gradle-managed")
JDK_HOME = os.environ.get(
"LOCAL_INSTRUMENTED_JDK", "C:/Program Files/Eclipse Adoptium/jdk-21.0.11.10-hotspot"
)
SERIAL = os.environ.get("LOCAL_INSTRUMENTED_SERIAL", "emulator-5554")
BOOT_TIMEOUT = int(os.environ.get("LOCAL_INSTRUMENTED_BOOT_TIMEOUT", "300"))
# Windows qemu/emulator image names (see FREEZE / HYGIENE above). The ``-headless`` variant is
# what a ``-no-window`` emulator launches; the plain qemu name is swept too, belt-and-suspenders.
QEMU_IMAGE = "qemu-system-x86_64-headless.exe"
QEMU_IMAGE_ALT = "qemu-system-x86_64.exe"
EMULATOR_IMAGE = "emulator.exe"
REPO_ROOT = Path(__file__).resolve().parents[3] # .claude/skills/preflight -> repo root
GRADLEW = REPO_ROOT / ("gradlew.bat" if IS_WINDOWS else "gradlew")
EMU_LOG = Path(tempfile.gettempdir()) / "libremail-local-instrumented-emulator.log"
class _RunState:
"""Mutable run state shared by main(), teardown(), the atexit hook and the signal
handlers -- mirrors the bash globals EMU_PID / TEST_EXIT / ZOMBIE / TEARDOWN_DONE."""
def __init__(self) -> None:
self.proc: subprocess.Popen | None = None
self.test_exit = 1
self.zombie = False
self.teardown_done = False
_STATE = _RunState()
def log(msg: str) -> None:
print(f"\n=== {msg} ===")
def warn(msg: str) -> None:
print(f"WARNING: {msg}", file=sys.stderr)
def die(msg: str) -> None:
"""Print an error and exit 2 (usage / precondition failure). Called before the teardown
backstops are armed, so nothing has booted and there is nothing to tear down."""
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(2)
def cmd(tool: str, *args: str) -> list[str]:
"""Build an argv list, wrapping Windows ``.bat``/``.cmd`` launchers (e.g. gradlew.bat)
through ``cmd /c`` -- matching api37_e2e.py. ``.exe`` tools pass through unchanged."""
if IS_WINDOWS and tool.lower().endswith((".bat", ".cmd")):
return ["cmd", "/c", tool, *args]
return [tool, *args]
def _run_quiet(argv: list[str]) -> None:
"""Run a command, discarding output and swallowing any error -- teardown/kill helpers
must always make progress."""
try:
subprocess.run(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
except (OSError, subprocess.SubprocessError):
pass
def _run_capture(argv: list[str]) -> str:
try:
return subprocess.run(argv, capture_output=True, text=True, check=False).stdout or ""
except (OSError, subprocess.SubprocessError):
return ""
def list_procs(*needles: str) -> str:
"""Return the lines of currently-running processes whose name/command line contains any
of ``needles`` (case-insensitive); empty string if none. Cross-platform stand-in for the
.sh's ``tasklist | grep``: ``tasklist`` on Windows, ``ps ax`` on *nix."""
out = _run_capture(["tasklist"] if IS_WINDOWS else ["ps", "ax"])
lowered = [n.lower() for n in needles]
return "\n".join(ln for ln in out.splitlines() if any(n in ln.lower() for n in lowered))
def list_qemu() -> str:
return list_procs("qemu")
def list_emu_procs() -> str:
return list_procs("qemu", "emulator")
def force_kill(win_images: list[str], nix_patterns: list[str]) -> None:
"""Best-effort force-kill. Windows: ``taskkill /F /IM <image> ...``. *nix: ``pkill -f
<pattern>`` per pattern. Never raises -- teardown must always make progress."""
if IS_WINDOWS:
argv = ["taskkill", "/F"]
for image in win_images:
argv += ["/IM", image]
_run_quiet(argv)
else:
for pattern in nix_patterns:
_run_quiet(["pkill", "-f", pattern])
def tail(path: Path, lines: int = 40) -> None:
try:
with open(path, "r", errors="replace") as handle:
content = handle.readlines()[-lines:]
print("".join(content), file=sys.stderr, end="")
except OSError:
pass
def teardown() -> None:
"""Kill the emulator and verify no orphaned emulator/qemu process remains. Idempotent --
safe to call from the finally block, the atexit hook and the signal handlers (mirrors the
.sh TEARDOWN_DONE guard). Sets _STATE.zombie if a *qemu* process survives the force-kill --
the machine-freezing case (exit 3)."""
if _STATE.teardown_done:
return
_STATE.teardown_done = True
log("Teardown: killing emulator and verifying no orphaned emulator/qemu remains")
adb = shutil.which("adb")
if adb:
_run_quiet(cmd(adb, "-s", SERIAL, "emu", "kill"))
time.sleep(2)
# Belt-and-suspenders: kill the emulator launcher process we started, if still alive.
proc = _STATE.proc
if proc is not None and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=1)
except subprocess.TimeoutExpired:
proc.kill()
# Reap any lingering emulator/qemu process, then verify. Unlike the original .sh -- which
# swept qemu ONLY -- we also force-kill the emulator *launcher* image: on Windows the
# ``-no-window`` emulator spawns a sibling ``emulator.exe`` that is NOT the Popen child we
# tracked and outlives both it and the qemu VM by a few seconds, so a qemu-only sweep
# returns while it is still shutting down -- an orphan the freeze-safety rule forbids. So we
# trigger on any emulator-or-qemu survivor and taskkill the launcher too.
if list_emu_procs():
warn("emulator/qemu still present after 'adb emu kill'; force-killing:")
print(list_emu_procs(), file=sys.stderr)
force_kill([QEMU_IMAGE, QEMU_IMAGE_ALT, EMULATOR_IMAGE], ["qemu-system"])
time.sleep(2)
# A surviving QEMU is the machine-freezing zombie (exit 3); a stray launcher is not.
remaining = list_qemu()
if remaining:
warn("qemu ZOMBIE survived teardown -- kill it by hand or the machine may freeze:")
print(remaining, file=sys.stderr)
_STATE.zombie = True
if adb:
_run_quiet(cmd(adb, "kill-server"))
def orphan_kill_preamble(adb: str) -> None:
"""Force-kill any pre-existing qemu/emulator processes and reset the adb server, so we
always cold-boot from a clean slate."""
log("Orphan-kill preamble: ensuring a clean slate before boot")
existing = list_emu_procs()
if existing:
warn("Pre-existing emulator/qemu processes found -- force-killing them first:")
print(existing, file=sys.stderr)
force_kill([QEMU_IMAGE, EMULATOR_IMAGE], ["qemu-system"])
time.sleep(2)
else:
print("No pre-existing qemu/emulator processes.")
_run_quiet(cmd(adb, "kill-server"))
_run_quiet(cmd(adb, "start-server"))
def start_emulator(emulator: str) -> subprocess.Popen:
"""Cold-boot ONE emulator by hand (no GMD, no snapshot), logging to EMU_LOG. Records the
launcher process in _STATE so teardown can reap it even if we are interrupted next."""
log(f"Cold-booting @{AVD_NAME} (no GMD, no snapshot); log -> {EMU_LOG}")
flags = [
f"@{AVD_NAME}",
"-no-window", "-no-snapshot", "-no-boot-anim", "-no-audio",
"-gpu", "auto-no-window", "-cores", "8", "-wipe-data",
]
logf = open(EMU_LOG, "wb") # noqa: SIM115 - handed to the child; parent copy closed below
try:
proc = subprocess.Popen(cmd(emulator, *flags), stdout=logf, stderr=subprocess.STDOUT)
finally:
logf.close() # the child inherited its own fd; the parent's copy is no longer needed
_STATE.proc = proc
print(f"emulator launcher pid={proc.pid}")
return proc
def wait_for_boot(adb: str, proc: subprocess.Popen, timeout: int) -> bool:
"""Poll ``adb get-state`` + ``getprop sys.boot_completed`` until the emulator is up, or the
launcher dies, or ``timeout`` seconds elapse. Mirrors the .sh boot loop."""
print(f"Waiting up to {timeout}s for sys.boot_completed on {SERIAL}...")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if proc.poll() is not None:
warn("emulator process exited during boot; last log lines:")
tail(EMU_LOG, 40)
return False
state = _run_capture(cmd(adb, "-s", SERIAL, "get-state")).strip()
if state == "device":
booted = _run_capture(
cmd(adb, "-s", SERIAL, "shell", "getprop", "sys.boot_completed")
).strip()
if booted == "1":
return True
time.sleep(3)
return False
def prepare_focus(adb: str) -> None:
# Force the emulator to grant the app window focus BEFORE the suite, then gate on it -- the
# SAME shared helper CI's e2e / e2e-preview jobs invoke (issue #468), so local preflight
# exercises the identical fix. It wakes the display, dismisses + disables the keyguard, keeps
# the screen on, disables animations, and waits for a focused window. Best-effort: fall back
# to the legacy `input keyevent 82` nudge if the shared helper is somehow missing.
gate = REPO_ROOT / ".github" / "scripts" / "emulator_focus_gate.py"
if gate.is_file():
subprocess.run([sys.executable, str(gate), "--adb", adb, "--serial", SERIAL], check=False)
else:
_run_quiet(cmd(adb, "-s", SERIAL, "shell", "input", "keyevent", "82"))
def run_tests(test_classes: str) -> int:
"""Run :app:connectedDebugAndroidTest filtered to ``test_classes`` from the repo root
(JAVA_HOME / ANDROID_AVD_HOME are already in the environment)."""
log(f"Running :app:connectedDebugAndroidTest for: {test_classes}")
print(f"JAVA_HOME={os.environ.get('JAVA_HOME', '')}")
return subprocess.run(
cmd(
str(GRADLEW),
":app:connectedDebugAndroidTest",
f"-Pandroid.testInstrumentationRunnerArguments.class={test_classes}",
"--stacktrace",
),
cwd=str(REPO_ROOT),
check=False,
).returncode
def main() -> int:
parser = argparse.ArgumentParser(
prog="local_instrumented.py",
formatter_class=argparse.RawDescriptionHelpFormatter,
description=(
"Cold-boot ONE emulator (no GMD, no snapshot) and run "
":app:connectedDebugAndroidTest filtered to the given instrumented test class(es)."
),
epilog=(
"Keep the class set small and targeted -- the full ~114-test suite tends to wedge\n"
"mid-run on this box (see the module docstring). The full matrix is CI's job.\n"
"Example:\n"
" python .claude/skills/preflight/local_instrumented.py \\\n"
" org.libremail.ui.compose.ComposeScreenE2ETest,org.libremail.ui.compose.RecipientChipTest"
),
)
parser.add_argument(
"test_classes",
metavar="TEST_CLASSES",
help=(
"Comma-separated fully-qualified instrumented test class(es), no spaces "
"(e.g. org.libremail.a.FooTest,org.libremail.b.BarTest)."
),
)
args = parser.parse_args()
# ---- preconditions (before arming teardown; nothing has booted yet) ------------------
emulator = shutil.which("emulator")
adb = shutil.which("adb")
if not emulator:
die("emulator not on PATH (install Android SDK emulator).")
if not adb:
die("adb not on PATH (install Android SDK platform-tools).")
kill_tool = "taskkill" if IS_WINDOWS else "pkill"
if not shutil.which(kill_tool):
die(f"{kill_tool} not found -- required to force-kill orphaned emulator/qemu processes.")
if not GRADLEW.is_file():
die(f"gradlew not found at {GRADLEW}.")
if not os.path.isdir(JDK_HOME):
die(f"JDK 17-21 not found at '{JDK_HOME}'. Set LOCAL_INSTRUMENTED_JDK.")
if not os.path.isfile(os.path.join(AVD_HOME, AVD_NAME + ".ini")):
die(
f"AVD '{AVD_NAME}' not found under '{AVD_HOME}'. "
"Set LOCAL_INSTRUMENTED_AVD / ANDROID_AVD_HOME. "
"(GMD AVDs are created by any local apiXXDebugAndroidTest run.)"
)
# Move gradlew's working dir to this tree's repo root (below) and pin JAVA_HOME/AVD home,
# exactly like the .sh -- the ambient JAVA_HOME on this box points at JDK 25 (AGP-incompatible).
os.environ["JAVA_HOME"] = JDK_HOME
os.environ["ANDROID_AVD_HOME"] = AVD_HOME
# ---- arm teardown backstops BEFORE touching the emulator -----------------------------
# try/finally is the primary path; atexit covers sys.exit()/unhandled-exception exits; the
# signal handlers make SIGINT/SIGTERM tear down too (Python does not raise on SIGTERM by
# default). teardown() is idempotent, so firing from several paths is safe (mirrors the
# .sh's ``trap teardown EXIT INT TERM`` + TEARDOWN_DONE guard).
atexit.register(teardown)
def _signal_teardown(signum: int, _frame: object) -> None:
teardown()
sys.exit(128 + signum)
signal.signal(signal.SIGINT, _signal_teardown)
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, _signal_teardown)
boot_failed = False
try:
orphan_kill_preamble(adb)
proc = start_emulator(emulator)
if wait_for_boot(adb, proc, BOOT_TIMEOUT):
print("Emulator booted.")
prepare_focus(adb)
_STATE.test_exit = run_tests(args.test_classes)
else:
warn(f"Emulator did not reach sys.boot_completed within {BOOT_TIMEOUT}s.")
tail(EMU_LOG, 40)
boot_failed = True
finally:
teardown()
if boot_failed:
return 4
if _STATE.zombie:
warn(
"Exiting 3: a qemu zombie was left behind (see above) -- "
"clean it up before the next run."
)
return 3
if _STATE.test_exit != 0:
warn(
f"connectedDebugAndroidTest failed (exit {_STATE.test_exit}). "
"Report: app/build/reports/androidTests/connected/"
)
return _STATE.test_exit
log(f"PASS -- instrumented tests green for: {args.test_classes}")
return 0
if __name__ == "__main__":
sys.exit(main())