ci(e2e): gate the emulator on window focus to fix the RootViewPicker flake (#468) #474
@@ -365,10 +365,18 @@ def main() -> int:
|
|||||||
if not booted:
|
if not booted:
|
||||||
raise RuntimeError("API 37 preview emulator failed to boot after 2 attempts.")
|
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.
|
# 4. Force the emulator to grant the app window focus, then GATE on it (the SAME shared
|
||||||
subprocess.run(cmd(adb, "shell", "input", "keyevent", "82"), check=False)
|
# 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]
|
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")
|
gradlew = repo_root / ("gradlew.bat" if IS_WINDOWS else "gradlew")
|
||||||
print(f"Running :app:connectedDebugAndroidTest against {AVD_NAME}...")
|
print(f"Running :app:connectedDebugAndroidTest against {AVD_NAME}...")
|
||||||
test_exit = subprocess.run(
|
test_exit = subprocess.run(
|
||||||
|
|||||||
@@ -324,10 +324,17 @@ def wait_for_boot(adb: str, proc: subprocess.Popen, timeout: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def dismiss_keyguard(adb: str) -> None:
|
def prepare_focus(adb: str) -> None:
|
||||||
# Dismiss the keyguard (mirrors CI + api37_e2e.py). Best-effort: a cold -wipe-data boot
|
# Force the emulator to grant the app window focus BEFORE the suite, then gate on it -- the
|
||||||
# rarely needs it, and the input service can lose a race right after boot.
|
# SAME shared helper CI's e2e / e2e-preview jobs invoke (issue #468), so local preflight
|
||||||
_run_quiet(cmd(adb, "-s", SERIAL, "shell", "input", "keyevent", "82"))
|
# 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:
|
def run_tests(test_classes: str) -> int:
|
||||||
@@ -420,7 +427,7 @@ def main() -> int:
|
|||||||
proc = start_emulator(emulator)
|
proc = start_emulator(emulator)
|
||||||
if wait_for_boot(adb, proc, BOOT_TIMEOUT):
|
if wait_for_boot(adb, proc, BOOT_TIMEOUT):
|
||||||
print("Emulator booted.")
|
print("Emulator booted.")
|
||||||
dismiss_keyguard(adb)
|
prepare_focus(adb)
|
||||||
_STATE.test_exit = run_tests(args.test_classes)
|
_STATE.test_exit = run_tests(args.test_classes)
|
||||||
else:
|
else:
|
||||||
warn(f"Emulator did not reach sys.boot_completed within {BOOT_TIMEOUT}s.")
|
warn(f"Emulator did not reach sys.boot_completed within {BOOT_TIMEOUT}s.")
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -632,12 +632,20 @@ jobs:
|
|||||||
for attempt in 1 2; do boot_emulator "$attempt" && { booted=1; break; }; done
|
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; }
|
[ "$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
|
# Focus/readiness gate (issue #468): the durable fix for the intermittent app-window-
|
||||||
# (parity with the replaced android-emulator-runner `disable-animations: true`).
|
# focus flake. Occasionally the launched activity window has has-window-focus=false for
|
||||||
adb shell input keyevent 82 || true
|
# the WHOLE run, so Espresso's RootViewPicker (onView().check(), Intents.intended(),
|
||||||
adb shell settings put global window_animation_scale 0.0 || true
|
# pressBack(), focus-dependent clipboard) times out after 10s and fails EVERY
|
||||||
adb shell settings put global transition_animation_scale 0.0 || true
|
# focus-dependent test at once while the ~280 pure-Compose tests pass (evidence: a
|
||||||
adb shell settings put global animator_duration_scale 0.0 || true
|
# 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
|
# 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
|
# `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
|
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; }
|
[ "$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
|
# 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
|
# the smoking gun WHILE this hand-provisioned emulator is still alive (it stays up until the
|
||||||
|
|||||||
Reference in New Issue
Block a user