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
This commit is contained in:
2026-07-08 20:32:16 -05:00
parent d7429dfef0
commit 58447d7d12
5 changed files with 432 additions and 15 deletions
+21 -7
View File
@@ -632,12 +632,20 @@ jobs:
for attempt in 1 2; do boot_emulator "$attempt" && { booted=1; break; }; done
[ "$booted" = "1" ] || { echo "::error::API ${{ matrix.api-level }} emulator failed to boot after 2 attempts"; exit 1; }
# NON-FATAL unlock (the boot-race fix) + disable animations for deterministic UI tests
# (parity with the replaced android-emulator-runner `disable-animations: true`).
adb shell input keyevent 82 || true
adb shell settings put global window_animation_scale 0.0 || true
adb shell settings put global transition_animation_scale 0.0 || true
adb shell settings put global animator_duration_scale 0.0 || true
# Focus/readiness gate (issue #468): the durable fix for the intermittent app-window-
# focus flake. Occasionally the launched activity window has has-window-focus=false for
# the WHOLE 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 tests pass (evidence: a
# failing E2E leg's logcat had has-window-focus=true ZERO times, both attempt + retry).
# The single `input keyevent 82` here was too weak (MENU no longer dismisses the modern
# keyguard, and races SystemUI coming up). This shared helper WAKES the display, dismisses
# + disables the keyguard, keeps the screen on, disables animations (parity with the
# replaced `disable-animations: true`), then WAITS until a real window holds input focus
# before the suite runs. It is the IDENTICAL mechanism the e2e-preview job and the local
# preflight runners invoke, so the fix cannot drift between jobs. Non-fatal (`|| true`),
# preserving #454's guarantee that the unlock never aborts the boot.
python3 .github/scripts/emulator_focus_gate.py --label "api${{ matrix.api-level }}" || true
# WEDGE (hang) smoking-gun capture (#404, restored to the matrix by #421). On the wrapper
# `timeout` below (exit 124), grab the smoking gun WHILE this hand-provisioned emulator is
@@ -968,7 +976,13 @@ jobs:
for attempt in 1 2; do boot_emulator "$attempt" && { booted=1; break; }; done
[ "$booted" = "1" ] || { echo "::error::API 37 preview emulator failed to boot after 2 attempts"; exit 1; }
adb shell input keyevent 82 || true
# Focus/readiness gate (issue #468) -- the IDENTICAL shared mechanism the `e2e` matrix job
# (and the local preflight runners) invoke, so the fix can't drift: wake the display,
# dismiss + disable the keyguard, keep the screen on, disable animations, then WAIT until a
# real window holds input focus before the suite runs. This replaces the lone `input
# keyevent 82` and, applied UNIFORMLY, also gives this preview job the animation-disable
# the matrix already had. Non-fatal (`|| true`) -- the unlock must never abort the boot.
python3 .github/scripts/emulator_focus_gate.py --label "api37-shard${{ matrix.shard }}" || true
# WEDGE (hang) smoking-gun capture (#404). On the wrapper `timeout` below (exit 124), grab
# the smoking gun WHILE this hand-provisioned emulator is still alive (it stays up until the