ci(android): make the emulator smoke survive a slow emulator, not app failures (#68)

Evidence from runs 39866280662, 39870244857 and 40091145294 shows the red
smoke runs were the emulator, not TGLock: on the software GPU the WebView
paints about one frame per second while the page re-renders every second,
so a tap's down/up can straddle a re-render and Chromium drops the click
(no setEnabled call at all in the failed Stop run). Play services and Pixel
Launcher post-boot work also starved system_server into foreign ANR dialogs.

- AOSP image, 4 cores, 4 GB RAM, no snapshot save; wait for CPU to settle
  after boot before launching.
- Answer foreign "isn't responding" dialogs with Wait and record them; a
  TGLock ANR/error dialog, crash or missing process still fails.
- Re-tap only when the button is unchanged 10s after a tap (at most 3 taps);
  foreground-service and SOCKS listener effects stay strictly asserted with
  a 45s budget sized for this emulator.
- Timed step markers in stdout and logcat, failure screenshot and window
  dump, one evidence artifact per run attempt.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Никита Sonic
2026-10-24 12:37:41 +00:00
committed by GitHub
parent 3aa6f413a3
commit 33322ea3a3
3 changed files with 178 additions and 39 deletions
+10 -5
View File
@@ -79,7 +79,7 @@ jobs:
name: Android 15 emulator smoke name: Android 15 emulator smoke
needs: apk needs: apk
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
timeout-minutes: 15 timeout-minutes: 25
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
@@ -91,20 +91,25 @@ jobs:
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm sudo udevadm trigger --name-match=kvm
# The AOSP image has no Play services or Pixel Launcher. Their post-boot
# work starved system_server on the 4-vCPU runner and produced foreign ANR
# dialogs over the app. The app needs neither; WebView is still present.
- name: Launch and exercise the native app - name: Launch and exercise the native app
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
with: with:
api-level: 35 api-level: 35
arch: x86_64 arch: x86_64
target: google_apis target: default
profile: pixel_2 profile: pixel_2
cores: 4
ram-size: 4096M
disable-animations: true disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none emulator-options: -no-window -no-snapshot -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: timeout 180 python3 scripts/android_smoke.py emulator-apk script: timeout 420 python3 scripts/android_smoke.py emulator-apk
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: android-emulator-smoke-evidence name: android-emulator-smoke-evidence-attempt-${{ github.run_attempt }}
path: android-smoke-evidence/ path: android-smoke-evidence/
if-no-files-found: warn if-no-files-found: warn
retention-days: 14 retention-days: 14
+7 -2
View File
@@ -57,8 +57,13 @@ restart, and explicit force-stop/relaunch. It verifies the foreground service an
performs a real SOCKS5 greeting through ADB port forwarding to the Rust listener. performs a real SOCKS5 greeting through ADB port forwarding to the Rust listener.
If buttons are inaccessible after a bounded wait, CI explicitly reports the If buttons are inaccessible after a bounded wait, CI explicitly reports the
lifecycle checks as skipped; a launch-only pass is not lifecycle evidence. lifecycle checks as skipped; a launch-only pass is not lifecycle evidence.
`android-emulator-smoke-evidence` retains the exact result, UI dumps, service `android-emulator-smoke-evidence-attempt-N` (one per run attempt) retains the
state and logcat. This does not test automatic low-memory eviction, battery exact result, timed steps, UI dumps, service state, logcat and, on failure, a
screenshot. The emulator is the AOSP image without Play services; foreign
system ANR dialogs are answered with Wait and recorded, while a crash, ANR or
error dialog of TGLock itself fails the job. A tap the WebView dropped (the
button is unchanged ten seconds later) is repeated at most twice; the service
and listener effects are then asserted with the same strictness. This does not test automatic low-memory eviction, battery
behavior, Telegram connectivity, or a physical phone. behavior, Telegram connectivity, or a physical phone.
Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's
+161 -32
View File
@@ -14,23 +14,103 @@ PACKAGE = "com.bysonic.tglock"
COMPONENT = f"{PACKAGE}/.MainActivity" COMPONENT = f"{PACKAGE}/.MainActivity"
EVIDENCE = Path("android-smoke-evidence") EVIDENCE = Path("android-smoke-evidence")
EVIDENCE.mkdir(exist_ok=True) EVIDENCE.mkdir(exist_ok=True)
RESULT = {"launch": "not_run", "lifecycle": "not_run"} RESULT = {"launch": "not_run", "lifecycle": "not_run", "foreign_dialogs": [], "taps": {}}
STARTED = time.monotonic()
# Budgets measured on the GitHub-hosted emulator, not on a phone. With the
# software GPU the WebView paints about one frame per second, and dumpsys
# itself can take seconds while system_server catches up after boot.
SETTLE_SECONDS = 120
VISIBLE_SECONDS = 60
EFFECT_SECONDS = 45
# A click that reaches the handler re-renders the button as "Подождите…" or
# the opposite action within a frame. Only a button left unchanged this long
# was never clicked.
RETAP_AFTER_SECONDS = 10
MAX_TAPS = 3
SYSTEM_DIALOG = re.compile(r"(Application Not Responding|Application Error): ([\w.:]+)")
def adb(*args, check=True, timeout=20): def adb(*args, check=True, timeout=30):
return subprocess.run( return subprocess.run(
["adb", *args], check=check, capture_output=True, text=True, ["adb", *args], check=check, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout, encoding="utf-8", errors="replace", timeout=timeout,
).stdout.strip() ).stdout.strip()
def wait_for(description, predicate, seconds=20): def step(message):
# The same marker in stdout and logcat lines up the job log with evidence.
line = f"[{time.monotonic() - STARTED:6.1f}s] {message}"
print(line, flush=True)
with open(EVIDENCE / "steps.txt", "a", encoding="utf-8") as steps:
steps.write(line + "\n")
adb("shell", "log", "-t", "tglock-smoke", json.dumps(message), check=False)
def system_dialog():
try:
windows = adb("shell", "dumpsys", "window", "windows", check=False)
except subprocess.SubprocessError:
return None
match = SYSTEM_DIALOG.search(windows)
return (match.group(1), match.group(2)) if match else None
def clear_foreign_dialogs():
# A loaded emulator regularly declares its own launcher or system apps
# unresponsive, and that dialog covers the WebView. Ours is a failure.
dialog = system_dialog()
if dialog is None:
return
kind, process = dialog
if process.split(":")[0] == PACKAGE:
raise AssertionError(f"{kind} dialog for {process}")
button = "android:id/aerr_wait" if kind == "Application Not Responding" else "android:id/aerr_close"
try:
point = node_center(ui_dump(f"dialog-{len(RESULT['foreign_dialogs'])}"),
lambda node: node.get("resource-id") == button)
except (subprocess.SubprocessError, ET.ParseError):
point = None
if point is not None:
adb("shell", "input", "tap", str(point[0]), str(point[1]))
outcome = f"tapped {button}" if point else "button not dumped yet"
RESULT["foreign_dialogs"].append(f"{kind}: {process}, {outcome}")
step(f"foreign dialog {kind}: {process}, {outcome}")
def wait_for(description, predicate, seconds=EFFECT_SECONDS):
deadline = time.monotonic() + seconds deadline = time.monotonic() + seconds
while time.monotonic() < deadline: while time.monotonic() < deadline:
clear_foreign_dialogs()
if predicate(): if predicate():
step(f"ok: {description}")
return return
time.sleep(1) time.sleep(1)
raise AssertionError(f"Timed out: {description}") raise AssertionError(f"Timed out after {seconds}s: {description}")
def cpu_idle_share(interval=3):
def sample():
values = list(map(int, adb("shell", "head", "-1", "/proc/stat").split()[1:]))
return values[3] + values[4], sum(values)
idle1, total1 = sample()
time.sleep(interval)
idle2, total2 = sample()
return (idle2 - idle1) / max(1, total2 - total1)
def settle():
# boot_completed fires long before package, launcher and dexopt work
# drains. Start the app on a quiet device instead of racing that work.
deadline = time.monotonic() + SETTLE_SECONDS
quiet = 0
idle = 0.0
while time.monotonic() < deadline and quiet < 2:
clear_foreign_dialogs()
idle = cpu_idle_share()
quiet = quiet + 1 if idle >= 0.6 else 0
RESULT["settle"] = f"{'quiet' if quiet >= 2 else 'still busy'} at {time.monotonic() - STARTED:.0f}s, {idle:.0%} idle"
step(f"settle: {RESULT['settle']}")
def ui_dump(label): def ui_dump(label):
@@ -47,12 +127,9 @@ def ui_dump(label):
return ET.fromstring(text) return ET.fromstring(text)
def label_node(tree, label): def node_center(tree, matches):
for node in tree.iter("node"): for node in tree.iter("node"):
# Android aggregates the decorative arrow into the button's accessible if matches(node):
# text (observed in the API35 CI dump). Accept only that exact suffix.
names = (node.get("text", "").strip(), node.get("content-desc", "").strip())
if any(name in (label, f"{label} →") for name in names):
bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", "")) bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
if bounds: if bounds:
x1, y1, x2, y2 = map(int, bounds.groups()) x1, y1, x2, y2 = map(int, bounds.groups())
@@ -61,17 +138,53 @@ def label_node(tree, label):
return None return None
def tap_label(label, stage): def label_node(tree, label):
for attempt in range(5): # Android aggregates the decorative arrow into the button's accessible
try: # text (observed in the API35 CI dump). Accept only that exact suffix.
point = label_node(ui_dump(f"{stage}-{attempt}"), label) return node_center(tree, lambda node: any(
except (subprocess.SubprocessError, ET.ParseError) as error: name in (label, f"{label} →")
(EVIDENCE / f"{stage}-{attempt}.error.txt").write_text(str(error), encoding="utf-8") for name in (node.get("text", "").strip(), node.get("content-desc", "").strip())
point = None ))
if point is not None:
adb("shell", "input", "tap", str(point[0]), str(point[1]))
def find_label(label, stage):
clear_foreign_dialogs()
try:
return label_node(ui_dump(stage), label)
except (subprocess.SubprocessError, ET.ParseError) as error:
(EVIDENCE / f"{stage}.error.txt").write_text(str(error), encoding="utf-8")
return None
def press(label, stage):
"""Tap a visible WebView action until the page shows it was clicked.
The page re-renders every second. At one frame per second the touch down
and up can straddle that re-render, and Chromium then drops the click: the
button stays exactly as it was and the handler never runs. Callers still
assert the service and listener effect strictly afterwards.
"""
deadline = time.monotonic() + VISIBLE_SECONDS
taps = 0
last_tap = 0.0
attempt = 0
while time.monotonic() < deadline:
point = find_label(label, f"{stage}-{attempt}")
attempt += 1
if point is None and taps:
RESULT["taps"][stage] = taps
step(f"{label}: clicked after {taps} tap(s)")
return return
if point is not None and (not taps or time.monotonic() - last_tap >= RETAP_AFTER_SECONDS):
if taps == MAX_TAPS:
raise AssertionError(f"{label} stayed unchanged after {taps} taps")
adb("shell", "input", "tap", str(point[0]), str(point[1]))
taps += 1
last_tap = time.monotonic()
step(f"{label}: tap {taps} at {point}")
time.sleep(1) time.sleep(1)
if taps:
raise AssertionError(f"{label} stayed unchanged {VISIBLE_SECONDS}s after {taps} tap(s)")
raise AssertionError(f"Visible action not found: {label}") raise AssertionError(f"Visible action not found: {label}")
@@ -85,7 +198,7 @@ def proxy_ready():
# ADB forwards to emulator loopback. A real SOCKS5 greeting proves the # ADB forwards to emulator loopback. A real SOCKS5 greeting proves the
# Rust listener is serving, beyond just a notification being displayed. # Rust listener is serving, beyond just a notification being displayed.
try: try:
with socket.create_connection(("127.0.0.1", 11080), timeout=1) as peer: with socket.create_connection(("127.0.0.1", 11080), timeout=3) as peer:
peer.sendall(bytes([5, 1, 0])) peer.sendall(bytes([5, 1, 0]))
return peer.recv(2) == bytes([5, 0]) return peer.recv(2) == bytes([5, 0])
except (OSError, TimeoutError): except (OSError, TimeoutError):
@@ -93,7 +206,7 @@ def proxy_ready():
def launch(): def launch():
adb("shell", "am", "start", "-W", "-n", COMPONENT) adb("shell", "am", "start", "-W", "-n", COMPONENT, timeout=60)
wait_for("Activity resumed", lambda: any( wait_for("Activity resumed", lambda: any(
"ResumedActivity" in line and PACKAGE in line "ResumedActivity" in line and PACKAGE in line
for line in adb("shell", "dumpsys", "activity", "activities").splitlines() for line in adb("shell", "dumpsys", "activity", "activities").splitlines()
@@ -104,17 +217,20 @@ def launch():
def main(): def main():
apks = sorted(Path(sys.argv[1]).rglob("*.apk")) apks = sorted(Path(sys.argv[1]).rglob("*.apk"))
assert len(apks) == 1, f"Expected one x86_64 APK, got {len(apks)}" assert len(apks) == 1, f"Expected one x86_64 APK, got {len(apks)}"
adb("install", "-r", str(apks[0]), timeout=60) adb("install", "-r", str(apks[0]), timeout=120)
adb("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS") adb("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS")
adb("logcat", "-c")
adb("forward", "tcp:11080", "tcp:1080") adb("forward", "tcp:11080", "tcp:1080")
settle()
adb("logcat", "-c")
step("launch")
launch() launch()
RESULT["launch"] = "passed" RESULT["launch"] = "passed"
deadline = time.monotonic() + 20 deadline = time.monotonic() + VISIBLE_SECONDS
tree = None tree = None
start_point = None start_point = None
attempt = 0 attempt = 0
while time.monotonic() < deadline: while time.monotonic() < deadline:
clear_foreign_dialogs()
try: try:
tree = ui_dump(f"launched-{attempt}") tree = ui_dump(f"launched-{attempt}")
start_point = label_node(tree, "Включить защиту") start_point = label_node(tree, "Включить защиту")
@@ -132,23 +248,25 @@ def main():
for node in tree.iter("node") for node in tree.iter("node")
), "App Activity is resumed but its WebView is absent" ), "App Activity is resumed but its WebView is absent"
if start_point is None: if start_point is None:
RESULT["lifecycle"] = "skipped: WebView Start not accessible after 20s" RESULT["lifecycle"] = f"skipped: WebView Start not accessible after {VISIBLE_SECONDS}s"
print("::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after 20s") print(f"::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after {VISIBLE_SECONDS}s")
return return
RESULT["lifecycle"] = "failed: lifecycle assertions incomplete" RESULT["lifecycle"] = "failed: lifecycle assertions incomplete"
tap_label("Включить защиту", "before-start") press("Включить защиту", "before-start")
wait_for("foreground service after Start", service_running) wait_for("foreground service after Start", service_running)
wait_for("Rust SOCKS listener after Start", proxy_ready) wait_for("Rust SOCKS listener after Start", proxy_ready)
adb("shell", "input", "keyevent", "KEYCODE_HOME") adb("shell", "input", "keyevent", "KEYCODE_HOME")
time.sleep(5) time.sleep(5)
clear_foreign_dialogs()
assert service_running() and proxy_ready(), "Proxy stopped after backgrounding" assert service_running() and proxy_ready(), "Proxy stopped after backgrounding"
step("ok: still serving after 5s in background")
launch() launch()
tap_label("Выключить", "before-stop") press("Выключить", "before-stop")
wait_for("foreground service after Stop", lambda: not service_running()) wait_for("foreground service after Stop", lambda: not service_running())
wait_for("Rust listener after Stop", lambda: not proxy_ready()) wait_for("Rust listener after Stop", lambda: not proxy_ready())
# Explicit restart and user force-stop, then relaunch. This tests the # Explicit restart and user force-stop, then relaunch. This tests the
# user-stop contract, not Android's automatic low-memory process eviction. # user-stop contract, not Android's automatic low-memory process eviction.
tap_label("Включить защиту", "before-restart") press("Включить защиту", "before-restart")
wait_for("Rust listener after restart", proxy_ready) wait_for("Rust listener after restart", proxy_ready)
adb("shell", "am", "force-stop", PACKAGE) adb("shell", "am", "force-stop", PACKAGE)
launch() launch()
@@ -160,10 +278,21 @@ try:
main() main()
except Exception as error: except Exception as error:
RESULT["failure"] = str(error) RESULT["failure"] = str(error)
for name, args in (("failure-windows.txt", ("dumpsys", "window", "windows")),
("failure-activities.txt", ("dumpsys", "activity", "activities"))):
try:
(EVIDENCE / name).write_text(adb("shell", *args, check=False), encoding="utf-8")
except subprocess.SubprocessError:
pass
try:
(EVIDENCE / "failure.png").write_bytes(subprocess.run(
["adb", "exec-out", "screencap", "-p"], capture_output=True, timeout=30).stdout)
except subprocess.SubprocessError:
pass
raise raise
finally: finally:
try: try:
logs = adb("logcat", "-d", "-v", "threadtime") logs = adb("logcat", "-d", "-v", "threadtime", timeout=60)
(EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8") (EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8")
crashes = adb("logcat", "-b", "crash", "-d") crashes = adb("logcat", "-b", "crash", "-d")
(EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8") (EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8")
@@ -171,9 +300,9 @@ finally:
RESULT["launch"] = "failed: application crash, ANR, or missing process" RESULT["launch"] = "failed: application crash, ANR, or missing process"
raise AssertionError("Application did not remain healthy") raise AssertionError("Application did not remain healthy")
finally: finally:
(EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2), encoding="utf-8") (EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2, ensure_ascii=False), encoding="utf-8")
summary = os.environ.get("GITHUB_STEP_SUMMARY") summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary: if summary:
with open(summary, "a", encoding="utf-8") as report: with open(summary, "a", encoding="utf-8") as report:
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT) + "\n") report.write("\nAndroid emulator smoke: " + json.dumps(RESULT, ensure_ascii=False) + "\n")
print(json.dumps(RESULT)) print(json.dumps(RESULT, ensure_ascii=False))