From 58447d7d12f17f2bec660394c93da53b9c7d1b8e Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 8 Jul 2026 20:32:16 -0500 Subject: [PATCH] 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 --- .claude/skills/preflight/api37_e2e.py | 14 +- .../skills/preflight/local_instrumented.py | 17 +- .github/scripts/emulator_focus_gate.py | 277 ++++++++++++++++++ .github/scripts/test_emulator_focus_gate.py | 111 +++++++ .github/workflows/ci.yml | 28 +- 5 files changed, 432 insertions(+), 15 deletions(-) create mode 100644 .github/scripts/emulator_focus_gate.py create mode 100644 .github/scripts/test_emulator_focus_gate.py diff --git a/.claude/skills/preflight/api37_e2e.py b/.claude/skills/preflight/api37_e2e.py index 14474aa..1421ded 100644 --- a/.claude/skills/preflight/api37_e2e.py +++ b/.claude/skills/preflight/api37_e2e.py @@ -365,10 +365,18 @@ def main() -> int: 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) - + # 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( diff --git a/.claude/skills/preflight/local_instrumented.py b/.claude/skills/preflight/local_instrumented.py index b31eeab..fc4bf35 100644 --- a/.claude/skills/preflight/local_instrumented.py +++ b/.claude/skills/preflight/local_instrumented.py @@ -324,10 +324,17 @@ def wait_for_boot(adb: str, proc: subprocess.Popen, timeout: int) -> bool: return False -def dismiss_keyguard(adb: str) -> None: - # Dismiss the keyguard (mirrors CI + api37_e2e.py). Best-effort: a cold -wipe-data boot - # rarely needs it, and the input service can lose a race right after boot. - _run_quiet(cmd(adb, "-s", SERIAL, "shell", "input", "keyevent", "82")) +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: @@ -420,7 +427,7 @@ def main() -> int: proc = start_emulator(emulator) if wait_for_boot(adb, proc, BOOT_TIMEOUT): print("Emulator booted.") - dismiss_keyguard(adb) + 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.") diff --git a/.github/scripts/emulator_focus_gate.py b/.github/scripts/emulator_focus_gate.py new file mode 100644 index 0000000..44ade49 --- /dev/null +++ b/.github/scripts/emulator_focus_gate.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""emulator_focus_gate.py -- make a booted emulator reliably grant the app window focus +BEFORE an instrumented UI suite runs, then GATE on that state (issue #468). + +WHY THIS EXISTS (issue #468 -- the environmental app-window-focus flake) +------------------------------------------------------------------------ +Intermittently, on the CI emulator the launched activity window has +``has-window-focus=false`` for the WHOLE instrumented run, so Espresso's ``RootViewPicker`` +(used by ``onView(...).check()``, ``Intents.intended()``, ``Espresso.pressBack()`` and +focus-dependent clipboard reads) waits 10s for a focused root and times out -- +``RootViewWithoutFocusException``. It fails EVERY window-focus-dependent test at once while +the ~280 pure-Compose semantics tests (which do not need window focus) pass. Root-cause +evidence from a failing ``E2E (35)`` leg (PR #470, run 28985259521): across the entire +captured logcat ``has-window-focus=true`` appears ZERO times and both the first attempt and +the once-retry fail identically -- i.e. the window NEVER gains focus for the session, a +persistent environmental state, not a per-test transient. + +The prior mitigation was a single fire-and-forget ``adb shell input keyevent 82`` (MENU) +right after ``sys.boot_completed=1``. On modern Android (API 30+) MENU does NOT reliably +dismiss the keyguard, and when it is delivered before SystemUI/keyguard finishes coming up it +is simply dropped ("no focused window"). The insecure keyguard / non-interactive display then +persists and no app window ever takes focus -- hence the intermittent, whole-leg flake. + +WHAT THIS DOES +-------------- +A single shared mechanism invoked identically by every E2E job (the ``e2e`` API 29-36 matrix +AND the ``e2e-preview`` API 37 job in ``.github/workflows/ci.yml``) and by the local preflight +runners (``local_instrumented.py`` / ``api37_e2e.py``), so the fix cannot drift between them: + + 1. PREPARE the device so an app window CAN take focus, and keep it that way for the whole + run (all best-effort; a missing service right after boot must never abort the leg): + * ``input keyevent WAKEUP`` (224) -- force the display INTERACTIVE (never toggles it + off the way POWER would). + * ``wm dismiss-keyguard`` -- dismiss the (insecure) keyguard now. + * ``locksettings set-disabled true`` -- disable the lock screen for the session so it + cannot re-curtain the app window mid-run. + * ``svc power stayon true`` + a max ``screen_off_timeout`` -- never sleep during the run. + * ``input keyevent 82`` (MENU) -- legacy nudge, kept harmless for parity with #454. + * zero the three animation scales -- deterministic UI tests (this also gives the + ``e2e-preview`` job the animation-disable the matrix already had -- uniformly). + 2. GATE: poll ``dumpsys power`` + ``dumpsys window`` until the device is interactive + (``mWakefulness=Awake``) AND a real window holds input focus (``mCurrentFocus`` is a + ``Window{...}``, not ``null``) -- i.e. the exact precondition ``RootViewPicker`` needs -- + re-issuing the wake / dismiss-keyguard nudges each iteration so a lost race self-heals. + +The gate is SOFT: it waits up to ``--timeout`` seconds and then proceeds regardless, printing a +GitHub ``::warning::`` annotation and the final device state if it never confirmed focus (the +determinism comes from the PREPARE actions + the wait; a parsing quirk on some API level must +not convert an otherwise-fine leg into a hard failure -- the real tests remain the arbiter). +It always prints the final ``mWakefulness`` / ``mCurrentFocus`` / keyguard state so a genuine +environmental failure is diagnosable from the step log without downloading artifacts. + +Pure standard library, cross-platform (Windows / Linux / macOS): ``adb`` is invoked via +subprocess. The readiness parser (``evaluate_readiness``) is a pure function, unit-tested by +``test_emulator_focus_gate.py`` (run by the ``traffic-control-tests`` CI job). +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +import time +from typing import NamedTuple + +# WAKEUP (not POWER): guarantees the display ends up INTERACTIVE. POWER (26) toggles, so it +# would turn an already-on display OFF. MENU (82) is kept only as a legacy parity nudge. +KEYCODE_WAKEUP = "224" +KEYCODE_MENU = "82" +# Max int -- effectively "never" auto-sleep the screen during the suite. +SCREEN_OFF_TIMEOUT_MS = "2147483647" +DEFAULT_TIMEOUT_S = 90 +POLL_INTERVAL_S = 2 + + +class Readiness(NamedTuple): + """Outcome of parsing ``dumpsys power`` + ``dumpsys window`` for focus readiness.""" + + ready: bool + awake: bool + focus_state: str # 'focused' | 'unfocused' | 'unknown' + focus_value: str # the mCurrentFocus / mFocusedWindow token, or '' + keyguard_state: str # 'showing' | 'not_showing' | 'unknown' + + @property + def summary(self) -> str: + return ( + f"awake={self.awake} focus={self.focus_state}" + f"({self.focus_value or '-'}) keyguard={self.keyguard_state}" + ) + + +def _is_awake(power_out: str) -> bool: + """True if ``dumpsys power`` reports an INTERACTIVE display. ``mWakefulness=Awake`` is the + stable signal across API 29-37; ``Display Power: state=ON`` / ``mInteractive=true`` are + accepted as fallbacks for dump-format drift.""" + return bool( + re.search(r"mWakefulness=Awake\b", power_out) + or re.search(r"Display Power:\s*state=ON\b", power_out) + or re.search(r"mInteractive=true\b", power_out) + ) + + +def _focus(window_out: str) -> tuple[str, str]: + """Classify the current input focus from ``dumpsys window``. + + Returns ``(state, value)`` where state is 'focused' (a non-null ``Window{...}`` holds + focus -- what RootViewPicker needs), 'unfocused' (focus is explicitly ``null`` -- asleep / + keyguard-curtained / no focusable window), or 'unknown' (the field is absent on this dump + format). ``mCurrentFocus`` is preferred; ``mFocusedWindow`` is the fallback field name.""" + tokens = re.findall(r"mCurrentFocus=(\S+)", window_out) + if not tokens: + tokens = re.findall(r"mFocusedWindow=(\S+)", window_out) + if not tokens: + return ("unknown", "") + non_null = [t for t in tokens if t != "null"] + if non_null: + return ("focused", non_null[0]) + return ("unfocused", "null") + + +def _keyguard(window_out: str) -> str: + """Best-effort keyguard state from ``dumpsys window``: 'showing' / 'not_showing' / + 'unknown'. Informational for the summary, plus a fallback readiness signal when the focus + field is absent. Field names vary by API level, so several are accepted.""" + match = re.search( + r"(?:mShowingLockscreen|mDreamingLockscreen|isKeyguardShowing|" + r"mKeyguardShowing|keyguardShowing|mKeyguardOccluded)=(true|false)", + window_out, + ) + if not match: + return "unknown" + return "showing" if match.group(1) == "true" else "not_showing" + + +def evaluate_readiness(power_out: str, window_out: str) -> Readiness: + """Pure decision core (unit-tested). The device is READY for a focus-dependent UI suite + when it is interactive AND a real window holds input focus. When the focus field is absent + on a given dump format, fall back to "interactive AND keyguard explicitly not showing" so a + format quirk cannot hang the gate forever.""" + awake = _is_awake(power_out) + focus_state, focus_value = _focus(window_out) + keyguard_state = _keyguard(window_out) + ready = awake and ( + focus_state == "focused" + or (focus_state == "unknown" and keyguard_state == "not_showing") + ) + return Readiness(ready, awake, focus_state, focus_value, keyguard_state) + + +def _adb_base(adb: str, serial: str | None) -> list[str]: + return [adb, "-s", serial] if serial else [adb] + + +def _adb_quiet(adb: str, serial: str | None, *args: str) -> None: + """Run an ``adb`` command, swallowing output and any error -- every prepare nudge is + best-effort (a service can lose a race right after boot; a missing tool must not abort).""" + try: + subprocess.run( + _adb_base(adb, serial) + list(args), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + pass + + +def _adb_capture(adb: str, serial: str | None, *args: str) -> str: + try: + return ( + subprocess.run( + _adb_base(adb, serial) + list(args), + capture_output=True, + text=True, + check=False, + timeout=30, + ).stdout + or "" + ) + except (OSError, subprocess.SubprocessError): + return "" + + +def nudge_focus(adb: str, serial: str | None) -> None: + """Wake the display + dismiss the keyguard. Cheap and idempotent, so it is re-issued every + poll iteration to self-heal a nudge that lost the post-boot race with SystemUI/keyguard.""" + _adb_quiet(adb, serial, "shell", "input", "keyevent", KEYCODE_WAKEUP) + _adb_quiet(adb, serial, "shell", "wm", "dismiss-keyguard") + + +def prepare_device(adb: str, serial: str | None) -> None: + """One-time device preparation: disable the lock screen for the session, keep the screen on + for the whole run, zero the animation scales for deterministic UI tests, and issue the first + wake / dismiss-keyguard nudge. All best-effort.""" + print("focus-gate: preparing device (wake + dismiss-keyguard + stay-awake + no-animations)") + nudge_focus(adb, serial) + _adb_quiet(adb, serial, "shell", "input", "keyevent", KEYCODE_MENU) # legacy #454 parity + _adb_quiet(adb, serial, "shell", "locksettings", "set-disabled", "true") + _adb_quiet(adb, serial, "shell", "svc", "power", "stayon", "true") + _adb_quiet(adb, serial, "shell", "settings", "put", "system", + "screen_off_timeout", SCREEN_OFF_TIMEOUT_MS) + for scale in ("window_animation_scale", "transition_animation_scale", + "animator_duration_scale"): + _adb_quiet(adb, serial, "shell", "settings", "put", "global", scale, "0.0") + + +def probe(adb: str, serial: str | None) -> Readiness: + power_out = _adb_capture(adb, serial, "shell", "dumpsys", "power") + window_out = _adb_capture(adb, serial, "shell", "dumpsys", "window") + return evaluate_readiness(power_out, window_out) + + +def wait_for_focus(adb: str, serial: str | None, timeout: int, label: str) -> Readiness: + """Prepare the device, then poll (re-nudging each iteration) until it is interactive with a + focused window, or ``timeout`` seconds elapse. Returns the final Readiness (SOFT gate: the + caller proceeds regardless -- see the module docstring).""" + tag = f" [{label}]" if label else "" + prepare_device(adb, serial) + deadline = time.monotonic() + timeout + last = probe(adb, serial) + attempt = 0 + while True: + if last.ready: + elapsed = timeout - max(0, int(deadline - time.monotonic())) + print(f"focus-gate{tag}: READY after ~{elapsed}s -- {last.summary}") + return last + if time.monotonic() >= deadline: + print(f"::warning::focus-gate{tag}: window focus NOT confirmed within {timeout}s " + f"-- proceeding anyway -- {last.summary}") + return last + attempt += 1 + if attempt % 5 == 0: + print(f"focus-gate{tag}: waiting for window focus -- {last.summary}") + nudge_focus(adb, serial) + time.sleep(POLL_INTERVAL_S) + last = probe(adb, serial) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="emulator_focus_gate.py", + description=( + "Force a booted emulator to grant the app window focus (wake + dismiss-keyguard + " + "stay-awake + no-animations) and gate on that state before an instrumented UI " + "suite runs. Shared by CI's e2e / e2e-preview jobs and the local preflight runners " + "(issue #468)." + ), + ) + parser.add_argument("--serial", default=None, + help="adb device serial (default: the single attached device).") + parser.add_argument("--adb", default=None, + help="Path to adb (default: resolve from PATH). For callers that resolve " + "adb from the SDK rather than PATH (e.g. api37_e2e.py).") + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_S, + help=f"Max seconds to wait for window focus (default {DEFAULT_TIMEOUT_S}).") + parser.add_argument("--label", default="", + help="Label for log lines (e.g. an API level), for multi-leg runs.") + args = parser.parse_args(argv) + + adb = args.adb or shutil.which("adb") + if not adb: + # Non-fatal by contract: never turn a missing-tool hiccup into a red leg. The suite that + # follows will surface a genuinely broken device. + print("::warning::focus-gate: adb not on PATH -- skipping focus preparation/gate") + return 0 + + wait_for_focus(adb, args.serial, args.timeout, args.label) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_emulator_focus_gate.py b/.github/scripts/test_emulator_focus_gate.py new file mode 100644 index 0000000..6afa073 --- /dev/null +++ b/.github/scripts/test_emulator_focus_gate.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Unit tests for the pure readiness parser of emulator_focus_gate.py (no adb, no emulator). + +Covers the decision core that decides whether a booted emulator is ready for a focus-dependent +instrumented UI suite (issue #468): interactive (``mWakefulness=Awake``) AND a real window holds +input focus (``mCurrentFocus`` is a non-null ``Window{...}``). The window-focus flake this guards +against is exactly the "awake but mCurrentFocus=null" state, so that case must read NOT ready.""" + +from __future__ import annotations + +import unittest + +import emulator_focus_gate as gate + +# A ``dumpsys power`` where the display is interactive vs. asleep. +POWER_AWAKE = "Power Manager State:\n mWakefulness=Awake\n mWakefulnessChanging=false\n" +POWER_ASLEEP = "Power Manager State:\n mWakefulness=Asleep\n mWakefulnessChanging=false\n" + +# ``dumpsys window`` with a focused app window (the healthy state RootViewPicker needs)... +WINDOW_FOCUSED = ( + " mCurrentFocus=Window{23e192a u0 org.libremail.app/org.libremail.MainActivity}\n" + " mFocusedApp=ActivityRecord{a1 u0 org.libremail.app/.MainActivity t9}\n" + " mDreamingLockscreen=false\n" +) +# ...and the flake state: interactive-parse aside, NO window holds focus. +WINDOW_NO_FOCUS = " mCurrentFocus=null\n mFocusedApp=null\n mDreamingLockscreen=true\n" + + +class AwakeParsingTests(unittest.TestCase): + def test_mwakefulness_awake(self) -> None: + self.assertTrue(gate._is_awake(POWER_AWAKE)) + + def test_mwakefulness_asleep(self) -> None: + self.assertFalse(gate._is_awake(POWER_ASLEEP)) + + def test_display_power_state_on_fallback(self) -> None: + self.assertTrue(gate._is_awake("Display Power: state=ON")) + + def test_minteractive_fallback(self) -> None: + self.assertTrue(gate._is_awake("mInteractive=true")) + + def test_empty_is_not_awake(self) -> None: + self.assertFalse(gate._is_awake("")) + + +class FocusParsingTests(unittest.TestCase): + def test_non_null_current_focus(self) -> None: + state, value = gate._focus(WINDOW_FOCUSED) + self.assertEqual(state, "focused") + self.assertTrue(value.startswith("Window{")) + + def test_null_current_focus(self) -> None: + self.assertEqual(gate._focus(WINDOW_NO_FOCUS), ("unfocused", "null")) + + def test_focused_window_fallback_field(self) -> None: + state, value = gate._focus("mFocusedWindow=Window{deadbeef u0 launcher}\n") + self.assertEqual(state, "focused") + self.assertEqual(value, "Window{deadbeef") + + def test_absent_focus_field_is_unknown(self) -> None: + self.assertEqual(gate._focus("no focus fields here"), ("unknown", "")) + + +class KeyguardParsingTests(unittest.TestCase): + def test_showing(self) -> None: + self.assertEqual(gate._keyguard("mDreamingLockscreen=true"), "showing") + + def test_not_showing(self) -> None: + self.assertEqual(gate._keyguard("isKeyguardShowing=false"), "not_showing") + + def test_unknown(self) -> None: + self.assertEqual(gate._keyguard("nothing relevant"), "unknown") + + +class EvaluateReadinessTests(unittest.TestCase): + def test_awake_and_focused_is_ready(self) -> None: + result = gate.evaluate_readiness(POWER_AWAKE, WINDOW_FOCUSED) + self.assertTrue(result.ready) + self.assertTrue(result.awake) + self.assertEqual(result.focus_state, "focused") + + def test_the_flake_awake_but_no_focus_is_not_ready(self) -> None: + # The exact issue #468 signature: display parses/awake but no window has focus. + result = gate.evaluate_readiness(POWER_AWAKE, WINDOW_NO_FOCUS) + self.assertFalse(result.ready) + + def test_asleep_even_with_focus_is_not_ready(self) -> None: + result = gate.evaluate_readiness(POWER_ASLEEP, WINDOW_FOCUSED) + self.assertFalse(result.ready) + + def test_unknown_focus_but_awake_and_keyguard_gone_is_ready(self) -> None: + # Fallback so a dump format without mCurrentFocus can't hang the gate forever. + result = gate.evaluate_readiness(POWER_AWAKE, "mDreamingLockscreen=false") + self.assertTrue(result.ready) + + def test_unknown_focus_and_keyguard_showing_is_not_ready(self) -> None: + result = gate.evaluate_readiness(POWER_AWAKE, "mDreamingLockscreen=true") + self.assertFalse(result.ready) + + def test_unknown_focus_and_keyguard_unknown_is_not_ready(self) -> None: + result = gate.evaluate_readiness(POWER_AWAKE, "") + self.assertFalse(result.ready) + + def test_summary_is_human_readable(self) -> None: + summary = gate.evaluate_readiness(POWER_AWAKE, WINDOW_FOCUSED).summary + self.assertIn("awake=True", summary) + self.assertIn("focus=focused", summary) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a03b1ce..5b27e4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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