ci(preflight): hand-provision API 37 preview E2E locally
Per the repo owner's decision, preflight now runs the API 37 preview emulator locally instead of leaving it to CI. Since there is no Gradle Managed Device DSL path to the nonstandard android-37.0 / google_apis_ps16k image, add a stdlib-only, cross-platform Python 3 helper (.claude/skills/preflight/api37_e2e.py) that mirrors CI's e2e-preview job EXACTLY: same system image string (system-images;android-37.0;google_apis_ps16k;x86_64), same emulator flags, same provisioning/boot sequence. It installs the image via sdkmanager, creates the AVD via avdmanager, cold-boots headless, waits for sys.boot_completed, runs :app:connectedDebugAndroidTest, then tears the emulator + AVD down. Cross-platform: per-OS tool discovery/suffixes and cmd /c wrapping for Windows .bat launchers. Update SKILL.md + CLAUDE.md so preflight runs api35 + api36 (GMDs) + api37 (this script), and the app/build.gradle.kts managed-devices comment now points at the script. Add a caveat that emulators need a free hardware hypervisor (VT-x/WHPX) — shut down VirtualBox/other VMs first or the AVD hangs at 0% CPU. Validated syntactically only (python -m py_compile + ast.parse + argparse --help); no emulator was booted and no build was run, to avoid contending with an in-progress api36 run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: preflight
|
||||
description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt) plus the top-of-matrix emulator E2E (currently API 35 + 36) before pushing or opening a PR. Mirrors the merge gate and runs the two highest stable API levels via their Gradle Managed Devices; the rest of the multi-API matrix and the API 37 preview job stay CI-only. Use before treating a change as done.
|
||||
description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt) plus the top-of-matrix emulator E2E (currently API 35 + 36 via Gradle Managed Devices, then API 37 preview via the hand-provisioning script) before pushing or opening a PR. Mirrors the merge gate; the rest of the multi-API matrix stays CI-only. Use before treating a change as done.
|
||||
---
|
||||
|
||||
# /preflight
|
||||
@@ -13,12 +13,20 @@ Run the same fast checks CI enforces on every PR, in order, and report the outco
|
||||
with a JDK/AGP version mismatch, check `java -version` / `JAVA_HOME` and point it at a 17–21
|
||||
JDK (e.g. Android Studio's bundled JBR) before retrying.
|
||||
- PowerShell: invoke the wrapper as `.\gradlew`. Git Bash / the Bash tool: `./gradlew`.
|
||||
- The final two E2E steps each boot an emulator through their own Gradle Managed Device, so the
|
||||
host needs hardware acceleration (WHPX on Windows, KVM on Linux, HVF on macOS). Gradle
|
||||
downloads the system image and boots/tears down each AVD itself — no manual emulator setup —
|
||||
but the first run per API level is slow while its image downloads. If the host has no
|
||||
accelerated emulator and a managed device cannot boot, report the E2E step as not run rather
|
||||
than treating the gate as green.
|
||||
- The final three E2E steps each boot an emulator, so the host needs a **free hardware
|
||||
hypervisor** (Intel VT-x / AMD-V, exposed as WHPX on Windows, KVM on Linux, HVF on macOS).
|
||||
Shut down VirtualBox, Hyper-V-based VMs, WSL2, Docker Desktop, or any other emulator first — a
|
||||
VM holding the hypervisor starves the AVD, and it hangs at 0% CPU and never reaches
|
||||
`sys.boot_completed`. The api35/api36 steps run through Gradle Managed Devices (Gradle
|
||||
downloads the image and boots/tears down each AVD itself); the api37 step is hand-provisioned
|
||||
by `api37_e2e.py` (see Steps). The first run per API level is slow while its system image
|
||||
downloads. If the host has no accelerated emulator and a device cannot boot, report the E2E
|
||||
step as not run rather than treating the gate as green.
|
||||
- The api37 step is a stdlib-only, cross-platform **Python 3** script and needs `python3` plus
|
||||
the Android SDK command-line tools (`sdkmanager`/`avdmanager`) and `emulator` on the machine,
|
||||
located via `ANDROID_SDK_ROOT`/`ANDROID_HOME` (or the per-OS default:
|
||||
`%LOCALAPPDATA%\Android\Sdk` on Windows, `~/Library/Android/sdk` on macOS, `~/Android/Sdk` on
|
||||
Linux). The script installs the preview system image itself on first run.
|
||||
|
||||
## Steps
|
||||
|
||||
@@ -32,6 +40,7 @@ Run these, stopping at the first failure:
|
||||
./gradlew :app:ktlintCheck :app:detekt
|
||||
./gradlew :app:api35DebugAndroidTest # top-of-matrix emulator E2E (2nd-highest stable level)
|
||||
./gradlew :app:api36DebugAndroidTest # top-of-matrix emulator E2E (highest stable level)
|
||||
python3 .claude/skills/preflight/api37_e2e.py # api37 preview E2E (hand-provisioned; on Windows: py or python)
|
||||
```
|
||||
|
||||
`compileDebugAndroidTestKotlin` compiles the `androidTest` source set — the E2E/instrumented
|
||||
@@ -45,32 +54,41 @@ merge gate even when the build and lint are green. Add `--continue` to any comma
|
||||
`:app:ktlintCheck :app:detekt --continue`) to collect every failure in one pass instead of
|
||||
stopping at the first.
|
||||
|
||||
`api35DebugAndroidTest` and `api36DebugAndroidTest` run last because they are the slowest: each
|
||||
runs the full instrumented/E2E suite — on `api35`, then `api36`, the top two stable levels in
|
||||
the E2E matrix — via its own Gradle Managed Device, which Gradle provisions, boots, and tears
|
||||
down automatically. These two levels are the E2E that preflight runs locally and both must
|
||||
pass; CI fans the same suite out across the whole API matrix (API 29–36) plus the API 37
|
||||
preview job. API 37 has no Gradle Managed Device yet — its only published system image is the
|
||||
nonstandard `android-37.0` / `google_apis_ps16k` pairing, which neither `ManagedVirtualDevice`'s
|
||||
`apiLevel` (Int) nor `apiPreview` (codename) DSL resolves (see the comment above
|
||||
`testOptions.managedDevices` in `app/build.gradle.kts`) — so CI's `e2e-preview` job provisions
|
||||
it by hand instead, and there is no `api37DebugAndroidTest` task to run here. Keep
|
||||
The three E2E steps run last because they are the slowest. `api35DebugAndroidTest` and
|
||||
`api36DebugAndroidTest` run the full instrumented/E2E suite on `api35`, then `api36` — the top
|
||||
two stable levels in the E2E matrix — each via its own Gradle Managed Device, which Gradle
|
||||
provisions, boots, and tears down automatically.
|
||||
|
||||
`api37_e2e.py` then runs the same suite on the **API 37 preview** emulator. API 37 has no Gradle
|
||||
Managed Device — its only published system image is the nonstandard `android-37.0` /
|
||||
`google_apis_ps16k` pairing, which neither `ManagedVirtualDevice`'s `apiLevel` (Int) nor
|
||||
`apiPreview` (codename) DSL resolves (see the comment above `testOptions.managedDevices` in
|
||||
`app/build.gradle.kts`), so there is no `api37DebugAndroidTest` task. The script hand-provisions
|
||||
it exactly the way CI's `e2e-preview` job does — installs the image with `sdkmanager`, creates
|
||||
the AVD with `avdmanager`, cold-boots it headless with the same emulator flags, waits for
|
||||
`sys.boot_completed`, runs `:app:connectedDebugAndroidTest`, then kills the emulator and deletes
|
||||
the AVD. Because it mirrors `e2e-preview`, local preflight == CI on API 37.
|
||||
|
||||
All three levels are the E2E that preflight runs locally and all three must pass; CI fans the
|
||||
same suite out across the whole matrix (API 29–36 in `e2e`, plus API 37 in `e2e-preview`). Keep
|
||||
`api35DebugAndroidTest` / `api36DebugAndroidTest` in lockstep with the top of the managed-device
|
||||
list in `app/build.gradle.kts` and the E2E matrix in `.github/workflows/ci.yml` — when a newer
|
||||
API level is added there, run the new top two instead.
|
||||
list in `app/build.gradle.kts`, and keep `api37_e2e.py` in lockstep with the `e2e-preview` job in
|
||||
`.github/workflows/ci.yml` (same image string + emulator flags) — when a newer API level is added
|
||||
there, run the new top levels instead.
|
||||
|
||||
## Reporting
|
||||
|
||||
- If everything passes, say so plainly (e.g. "preflight green: build, unit tests, lint, ktlint, detekt, api35+api36 E2E").
|
||||
- If everything passes, say so plainly (e.g. "preflight green: build, unit tests, lint, ktlint, detekt, api35+api36+api37 E2E").
|
||||
- On failure, surface the actual Gradle error and point at the relevant report:
|
||||
- unit tests → `app/build/reports/tests/testDebugUnitTest/`
|
||||
- lint → `app/build/reports/lint-results-debug.html`
|
||||
- ktlint → `app/build/reports/ktlint/` (per source set, e.g. `ktlintTestSourceSetCheck/`)
|
||||
- detekt → `app/build/reports/detekt/`
|
||||
- E2E → `app/build/reports/androidTests/managedDevice/` (per-device HTML, e.g. `.../api35/`,
|
||||
`.../api36/`)
|
||||
- Run only the **top two stable-API** levels here (`api35DebugAndroidTest` +
|
||||
`api36DebugAndroidTest`); the rest of the multi-API matrix and the API 37 preview job stay
|
||||
CI's job — API 37 has no Gradle Managed Device to run locally (see Steps above). If the host
|
||||
has no accelerated emulator and a managed device cannot boot, report that E2E could not run
|
||||
rather than treating the gate as green.
|
||||
- api35/api36 E2E → `app/build/reports/androidTests/managedDevice/` (per-device HTML, e.g.
|
||||
`.../api35/`, `.../api36/`)
|
||||
- api37 E2E → `app/build/reports/androidTests/connected/` (the `connectedDebugAndroidTest`
|
||||
report the script drives); the emulator's own boot log is at the temp path the script prints.
|
||||
- Run the **top two stable-API** levels (`api35DebugAndroidTest` + `api36DebugAndroidTest`) plus
|
||||
the **API 37 preview** via `api37_e2e.py`; the rest of the multi-API matrix (API 29–34) stays
|
||||
CI's job. If the host has no accelerated emulator and a device cannot boot (see the hypervisor
|
||||
note in Preconditions), report that E2E could not run rather than treating the gate as green.
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/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, mirroring CI's `e2e-preview` job in
|
||||
.github/workflows/ci.yml EXACTLY (same system image string, same emulator flags, same
|
||||
provisioning/boot sequence) so that local preflight == CI. Keep the two in lockstep: 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 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
|
||||
|
||||
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 EXACTLY (cold headless boot, SwiftShader
|
||||
# GPU, hardware accel required, no cameras). Keep in lockstep with that job.
|
||||
flags = [
|
||||
"-avd", AVD_NAME,
|
||||
"-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", "-accel", "on",
|
||||
"-gpu", "swiftshader_indirect", "-camera-back", "none", "-camera-front", "none",
|
||||
]
|
||||
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 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"
|
||||
adb: str | None = None
|
||||
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).
|
||||
booted = False
|
||||
for attempt in (1, 2):
|
||||
proc = start_emulator(emulator, emu_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)
|
||||
tail(emu_log)
|
||||
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. Dismiss the keyguard, then run the instrumented/E2E suite against the booted emulator.
|
||||
subprocess.run(cmd(adb, "shell", "input", "keyevent", "82"), check=False)
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
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_emulator(adb, proc)
|
||||
subprocess.run(cmd(avdmanager, "delete", "avd", "-n", AVD_NAME), check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
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())
|
||||
@@ -28,18 +28,21 @@ or via Gradle Managed Devices `./gradlew e2eGroupDebugAndroidTest` (whole matrix
|
||||
|
||||
**Before treating a change as done**, run the fast CI gate: `assembleDebug` +
|
||||
`testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` +
|
||||
`detekt` + the top-of-matrix emulator E2E `api35DebugAndroidTest` + `api36DebugAndroidTest`
|
||||
(the `/preflight` skill does all of this). `compileDebugAndroidTestKotlin` compiles the
|
||||
`androidTest` source set that the static part of the gate skips, catching E2E/instrumented-test
|
||||
compile errors before they surface only in CI. `ktlintCheck`/`detekt` cover the
|
||||
`test`/`androidTest` source sets that `lintDebug` skips, so they catch style violations that
|
||||
would otherwise fail CI's Static analysis gate. `api35DebugAndroidTest` and
|
||||
`api36DebugAndroidTest` run the instrumented/E2E suite on the top two stable API levels in the
|
||||
E2E matrix, each via its own Gradle Managed Device (Gradle boots and tears down each emulator
|
||||
automatically). Running those two levels locally is required; the rest of the multi-API matrix
|
||||
(API 29–34) stays CI's job. API 37 (preview) still has no Gradle Managed Device to run locally
|
||||
— see the comment above `testOptions.managedDevices` in `app/build.gradle.kts` for why — so it
|
||||
remains exercised only by CI's `e2e-preview` job.
|
||||
`detekt` + the top-of-matrix emulator E2E `api35DebugAndroidTest` + `api36DebugAndroidTest` +
|
||||
the API 37 preview E2E via `python3 .claude/skills/preflight/api37_e2e.py` (the `/preflight`
|
||||
skill does all of this). `compileDebugAndroidTestKotlin` compiles the `androidTest` source set
|
||||
that the static part of the gate skips, catching E2E/instrumented-test compile errors before
|
||||
they surface only in CI. `ktlintCheck`/`detekt` cover the `test`/`androidTest` source sets that
|
||||
`lintDebug` skips, so they catch style violations that would otherwise fail CI's Static analysis
|
||||
gate. `api35DebugAndroidTest` and `api36DebugAndroidTest` run the instrumented/E2E suite on the
|
||||
top two stable API levels in the E2E matrix, each via its own Gradle Managed Device (Gradle boots
|
||||
and tears down each emulator automatically). API 37 (preview) has no Gradle Managed Device — its
|
||||
only image is the nonstandard `android-37.0` / `google_apis_ps16k` pairing (see the comment above
|
||||
`testOptions.managedDevices` in `app/build.gradle.kts`) — so `api37_e2e.py` hand-provisions it,
|
||||
mirroring CI's `e2e-preview` job (same image + emulator flags), boots it headless, runs
|
||||
`connectedDebugAndroidTest`, and tears it down. Running all three levels locally is required; the
|
||||
rest of the multi-API matrix (API 29–34) stays CI's job. Emulators need a free hardware
|
||||
hypervisor (VT-x/WHPX), so shut down VirtualBox/other VMs first or the AVD hangs at 0% CPU.
|
||||
|
||||
## Build-config gotchas
|
||||
|
||||
@@ -73,10 +76,11 @@ pulled in as a real dependency for unit tests because `android.jar`'s version is
|
||||
A change is not done until it ships with passing **unit tests** and **E2E/instrumented tests**
|
||||
that exercise the new or changed behaviour. Writing and committing that E2E/instrumented test
|
||||
is a required part of every task — and the test must actually **run and pass**, not merely
|
||||
compile: preflight runs the top-of-matrix emulator E2E locally (`api35DebugAndroidTest` +
|
||||
`api36DebugAndroidTest`, the two highest stable API levels in the E2E matrix and their Gradle
|
||||
Managed Device tasks) and both must be green before the change is done. CI then runs the full
|
||||
multi-API matrix plus the API 37 preview job.
|
||||
compile: preflight runs the top-of-matrix emulator E2E locally — `api35DebugAndroidTest` +
|
||||
`api36DebugAndroidTest` (Gradle Managed Devices) plus the API 37 preview via
|
||||
`api37_e2e.py` (hand-provisioned, mirroring CI's `e2e-preview` job) — and all three must be
|
||||
green before the change is done. CI then runs the full multi-API matrix plus the API 37 preview
|
||||
job.
|
||||
|
||||
## Repo etiquette
|
||||
|
||||
|
||||
@@ -157,8 +157,11 @@ android {
|
||||
// no DSL path to this image today, the same root cause documented on e2e-preview for why
|
||||
// reactivecircus/android-emulator-runner can't provision it either. issue #124's perf doc
|
||||
// (docs/perf/issue-124-unified-inbox-paging.md) independently corroborates this: its API 37
|
||||
// cross-check used a physical Pixel, not an emulator/AVD. Once a managed-device-compatible
|
||||
// image is published, add `api37` here (and to the CI matrix) and delete the e2e-preview job.
|
||||
// cross-check used a physical Pixel, not an emulator/AVD. Locally, preflight covers API 37
|
||||
// by hand-provisioning it with .claude/skills/preflight/api37_e2e.py, which mirrors the
|
||||
// e2e-preview job (same image + emulator flags). Once a managed-device-compatible image is
|
||||
// published, add `api37` here (and to the CI matrix), delete that script, and drop the
|
||||
// e2e-preview job.
|
||||
managedDevices {
|
||||
localDevices {
|
||||
listOf(29, 30, 31, 32, 33, 34, 35, 36).forEach { api ->
|
||||
|
||||
Reference in New Issue
Block a user