diff --git a/.github/workflows/android-device.yml b/.github/workflows/android-device.yml index 31a375d..697f583 100644 --- a/.github/workflows/android-device.yml +++ b/.github/workflows/android-device.yml @@ -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: | @@ -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 @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index def5d56..d66b0cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 8fc9ad8..1a37531 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -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. diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index a963653..2436d39 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -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 diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 52fb394..4939bd0 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -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] @@ -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(): @@ -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) diff --git a/scripts/tests/test_android_device_tests.py b/scripts/tests/test_android_device_tests.py index b3b8dcc..62553ac 100644 --- a/scripts/tests/test_android_device_tests.py +++ b/scripts/tests/test_android_device_tests.py @@ -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",