ci(e2e): gate the emulator on window focus to fix the RootViewPicker flake (#468) #474

Merged
JMR-dev merged 1 commits from fix-468-emulator-focus into main 2026-07-09 02:19:38 +00:00
5 changed files with 432 additions and 15 deletions
+11 -3
View File
@@ -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(
+12 -5
View File
@@ -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.")
+277
View File
@@ -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())
+111
View File
@@ -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()
+21 -7
View File
@@ -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