Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 2 additions & 34 deletions .github/workflows/android-device.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ jobs:
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 trigger --name-match=kvm
- name: Install SDK and fingerprint snapshot dependencies
id: sdk
- name: Install SDK and check acceleration
env:
DEVICE_API: ${{ inputs.api }}
run: |
Expand All @@ -55,37 +54,6 @@ jobs:
fi
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
"$ANDROID_HOME/emulator/emulator" -accel-check
snapshot_hash=$(cat "$ANDROID_HOME/emulator/source.properties" "$ANDROID_HOME/system-images/android-$DEVICE_API/google_apis/x86_64/source.properties" | sha256sum | cut -d ' ' -f 1)
echo "snapshot-hash=$snapshot_hash" >> "$GITHUB_OUTPUT"
- name: Restore clean emulator snapshot
id: avd
uses: actions/cache/restore@v4
with:
path: |
~/.android/avd/megaproxy-device.ini
~/.android/avd/megaproxy-device.avd/
key: android-device-v1-${{ runner.os }}-api${{ inputs.api }}-google-apis-x86_64-${{ steps.sdk.outputs.snapshot-hash }}-${{ hashFiles('.github/workflows/android-device.yml') }}
- name: Generate clean snapshot on cache miss
if: steps.avd.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2.38.0
with:
api-level: ${{ inputs.api }}
target: google_apis
arch: x86_64
avd-name: megaproxy-device
profile: pixel_5
force-avd-creation: false
emulator-options: -accel on -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none
disable-animations: false
script: adb shell input keyevent 82
- name: Save clean snapshot before installing MegaProxy
if: steps.avd.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: |
~/.android/avd/megaproxy-device.ini
~/.android/avd/megaproxy-device.avd/
key: ${{ steps.avd.outputs.cache-primary-key }}
- name: Build debug and instrumentation APKs
run: bundle exec fastlane android device_test_build
- name: Run real Android tests
Expand All @@ -99,7 +67,7 @@ jobs:
avd-name: megaproxy-device
profile: pixel_5
force-avd-creation: false
emulator-options: -accel on -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none
emulator-options: -accel on -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none
disable-animations: true
script: |
mkdir -p test-results/android-api${{ inputs.api }}
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ branch names, credentials, signing material, or other secrets.

- Pull requests must run native tests, Android JVM unit/lint/build checks and independent
Android emulator API 26/API 35 integration scenarios. Use Ubuntu 24.04 with explicit KVM
permissions, acceleration checks and clean snapshot caches, following BrowserMegaProxy.
permissions and acceleration checks, following BrowserMegaProxy. Both APIs cold boot without
snapshot caches to avoid restored-state startup failures and ADB disconnects.
- Compare each suite against its last successful ancestor check in the same PR and base.
Failed/skipped/cancelled jobs do not advance coverage. Fall back to the full PR diff when
history is unavailable; unknown paths and shared build/CI inputs enable all suites. Require `Change scope`, `Python tests and style` and both emulator scenarios
Expand Down
5 changes: 3 additions & 2 deletions docs/en/fastlane.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,9 @@ Release branches `release/vX.Y.Z` always run all CI suites; skipped jobs cannot
## Android emulator integration tests

CI runs API 26 and API 35 as independent jobs with independent successful-history baselines.
Both use Ubuntu 24.04, explicit KVM permissions, mandatory hardware acceleration and clean AVD
snapshots keyed by API, emulator/system-image versions and workflow content. No software fallback
Both use Ubuntu 24.04, explicit KVM permissions and mandatory hardware acceleration. Both APIs
cold boot without snapshot caches to avoid the failures observed after snapshot restore:
pre-test input failures on API 26 and ADB disconnects during diagnostics on API 35. No software fallback
or automatic test retry is used. Emulator startup gets one retry only if the test runner has
not started; a runner-started marker prevents retrying any test/setup failure inside the runner.
SDK installation also gets one retry for transient download failures.
Expand Down
5 changes: 3 additions & 2 deletions docs/ru/fastlane.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,9 @@ Release-ветки `release/vX.Y.Z` всегда запускают все на
## Интеграционные тесты на Android-эмуляторах

CI запускает API 26 и API 35 отдельными заданиями с независимой историей успешных проверок.
Оба используют Ubuntu 24.04, явные разрешения KVM, обязательное аппаратное ускорение и чистые
снимки AVD. Ключ кеша включает API, версии эмулятора/system image и содержимое workflow.
Оба используют Ubuntu 24.04, явные разрешения KVM и обязательное аппаратное ускорение. Оба API
загружаются с нуля без кеша снимков, чтобы избежать сбоев, наблюдавшихся после восстановления:
ошибки команды ввода перед тестами на API 26 и обрывов ADB при сборе диагностики на API 35.
Программного fallback и автоматических повторов тестов нет. Запуск эмулятора повторяется
один раз, только если тестовый runner ещё не стартовал. Маркер runner-started запрещает
повторять ошибки тестов и подготовки внутри runner. Две ошибки запуска оставляют required check
Expand Down
45 changes: 31 additions & 14 deletions scripts/android_device_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,33 @@ def require_success(output):
raise RuntimeError("Instrumentation did not report exactly one successful test")


def finish_case(shell, results, label, case, suite, log_start):
error_message = None
for name, words in (
("logcat", ("logcat", "-d", "-v", "threadtime", "-T", log_start)),
("force-stop", ("am", "force-stop", PACKAGE)),
):
try:
output = shell(*words)
except subprocess.SubprocessError as error:
output = getattr(error, "output", None) or ""
if isinstance(output, bytes):
output = output.decode(errors="replace")
message = f"{name} failed ({type(error).__name__}, exit {getattr(error, 'returncode', 'unknown')})"
ET.SubElement(case, "failure", message=message)
error_message = error_message or message
if name == "logcat" or error_message:
(results / (label + f"-{name}.txt")).write_text(output)
suite.set("tests", str(len(suite)))
suite.set("failures", str(sum(c.find("failure") is not None for c in suite)))
suite.set("time", f"{sum(float(c.get('time', '0')) for c in suite):.3f}")
ET.ElementTree(suite).write(
results / "junit.xml", encoding="utf-8", xml_declaration=True
)
if error_message:
raise RuntimeError(error_message + "; see collected evidence")


@contextlib.contextmanager
def fixture():
name = "megaproxy-device-" + uuid.uuid4().hex[:12]
Expand Down Expand Up @@ -193,7 +220,9 @@ def handle(self):
release.set()
server.shutdown()
worker.join()
shell("rm", "-f", accepted_path, release_path)
# The disposable emulator is discarded on failure; preserve the cause.
if sys.exc_info()[0] is None:
shell("rm", "-f", accepted_path, release_path)


def main():
Expand Down Expand Up @@ -324,19 +353,7 @@ def shell(*words):
print(f"Failure {extension} evidence unavailable", flush=True)
finally:
case.set("time", f"{time.monotonic() - started:.3f}")
(results / (label + "-logcat.txt")).write_text(
shell("logcat", "-d", "-v", "threadtime", "-T", log_start)
)
shell("am", "force-stop", PACKAGE)
suite.set("tests", str(len(suite)))
suite.set("failures", str(failures))
suite.set(
"time",
f"{sum(float(item.get('time', '0')) for item in suite):.3f}",
)
ET.ElementTree(suite).write(
results / "junit.xml", encoding="utf-8", xml_declaration=True
)
finish_case(shell, results, label, case, suite, log_start)
return bool(failures)


Expand Down
30 changes: 30 additions & 0 deletions scripts/tests/test_android_device_tests.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,42 @@
import subprocess
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
from pathlib import Path
from unittest.mock import Mock

sys.path.insert(0, str(Path(__file__).parents[1]))
import android_device_tests as m


class InstrumentationResultTest(unittest.TestCase):
def test_logcat_failure_preserves_evidence_and_failed_report(self):
suite = ET.Element("testsuite")
case = ET.SubElement(suite, "testcase", time="1.0")
shell = Mock(
side_effect=[
subprocess.CalledProcessError(
255, "logcat", output="read: unexpected EOF!"
),
subprocess.CalledProcessError(1, "force-stop", output="device offline"),
]
)
with tempfile.TemporaryDirectory() as directory:
results = Path(directory)
with self.assertRaisesRegex(RuntimeError, "logcat failed"):
m.finish_case(shell, results, "test", case, suite, "10-07 10:58:32.000")
report = ET.parse(results / "junit.xml").getroot()
self.assertEqual(report.get("tests"), "1")
self.assertEqual(report.get("failures"), "1")
self.assertEqual(len(report[0].findall("failure")), 2)
self.assertEqual(
(results / "test-logcat.txt").read_text(), "read: unexpected EOF!"
)
self.assertEqual(
(results / "test-force-stop.txt").read_text(), "device offline"
)

def test_adb_success_is_not_enough(self):
for output in (
"INSTRUMENTATION_CODE: 0",
Expand Down
Loading