From 8b0998721cbd744385afaab133843608601251a1 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 27 Oct 2026 22:10:39 +0300 Subject: [PATCH 1/3] Fix device fixture port allocation and per-case evidence --- app/build.gradle.kts | 3 + .../java/net/megaproxy487/DeviceTestBase.kt | 82 +++++++++++-------- .../megaproxy487/DeviceTestEvidenceRule.kt | 40 +++++++++ .../DeviceTestEvidenceRuleTest.kt | 60 ++++++++++++++ docs/en/release-testing.md | 2 + docs/ru/release-testing.md | 2 + scripts/android_device_tests.py | 78 ++++++++++-------- scripts/tests/test_android_device_tests.py | 78 +++++++++++++++++- 8 files changed, 276 insertions(+), 69 deletions(-) create mode 100644 app/src/sharedTest/java/net/megaproxy487/DeviceTestEvidenceRule.kt create mode 100644 app/src/test/java/net/megaproxy487/DeviceTestEvidenceRuleTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dbab17c..4304def 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,6 +55,9 @@ android { include("*.schema.json") } sourceSets.getByName("test").resources.srcDir(rootProject.file("config-schema")) + // Device-test lifecycle logic also runs as deterministic JVM tests. + sourceSets.getByName("test").java.srcDir("src/sharedTest/java") + sourceSets.getByName("androidTest").java.srcDir("src/sharedTest/java") defaultConfig { applicationId = "net.megaproxy487" diff --git a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt index b01c0ba..a26ad21 100644 --- a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt +++ b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt @@ -26,10 +26,8 @@ import net.megaproxy487.vpn.VpnConnectionState import net.megaproxy487.vpn.VpnRuntimeState import org.junit.Assert.* import org.junit.Rule -import org.junit.rules.ExternalResource -import org.junit.rules.RuleChain -import org.junit.rules.TestWatcher -import org.junit.runner.Description +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit import java.io.File import java.net.InetAddress import java.net.InetSocketAddress @@ -159,23 +157,27 @@ abstract class DeviceTestBase { }.isSuccess) } - private val platform = object : ExternalResource() { - override fun before() { - listOf("png", "xml").forEach { File(context.getExternalFilesDir(null), "device-failure.$it").delete() } - if (arguments.getString("phase") != "verify") io { - assertTrue(context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE).edit().clear().commit()) - val profile = store.activeProfile() - store.saveProfile(profile.copy(name = "Device fixture", config = ProxyConfig( - host = "10.0.2.2", port = argument("proxyPort").toInt(), username = "exit", - password = argument("proxyPassword"), allowInvalidProxyCertificate = true, - ))) - store.saveGlobalConnectionSettings(GlobalConnectionSettings(bypassLocalNetworks = false)) - } - assertTrue(context.getSharedPreferences("battery_optimization_reminder", Context.MODE_PRIVATE) - .edit().putLong("last_request_at", System.currentTimeMillis()).commit()) - scenario = ActivityScenario.launch(MainActivity::class.java) - device.waitForIdle() + private fun prepare() { + io { + PersistentDiagnosticLog.initialize(context, PersistentDiagnosticLog.DEFAULT_LIMIT_MB) + PersistentDiagnosticLog.clear() + flushDiagnosticLog() + PersistentDiagnosticLog.write("event=device_test stage=start") + } + listOf("png", "xml").forEach { File(context.getExternalFilesDir(null), "device-failure.$it").delete() } + if (arguments.getString("phase") != "verify") io { + assertTrue(context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE).edit().clear().commit()) + val profile = store.activeProfile() + store.saveProfile(profile.copy(name = "Device fixture", config = ProxyConfig( + host = "10.0.2.2", port = argument("proxyPort").toInt(), username = "exit", + password = argument("proxyPassword"), allowInvalidProxyCertificate = true, + ))) + store.saveGlobalConnectionSettings(GlobalConnectionSettings(bypassLocalNetworks = false)) } + assertTrue(context.getSharedPreferences("battery_optimization_reminder", Context.MODE_PRIVATE) + .edit().putLong("last_request_at", System.currentTimeMillis()).commit()) + scenario = ActivityScenario.launch(MainActivity::class.java) + device.waitForIdle() } private fun cleanup() { repeat(3) { @@ -191,21 +193,31 @@ abstract class DeviceTestBase { saved() } } - private val evidence = object : TestWatcher() { - override fun failed(error: Throwable, description: Description) { - // The disposable fixture uses synthetic credentials. This is the app's - // sanitized, bounded log, including recovery events absent from logcat. - println("MegaProxy diagnostic tail:\n${PersistentDiagnosticLog.readTail(32 * 1024)}") - val directory = context.getExternalFilesDir(null)!! - device.takeScreenshot(File(directory, "device-failure.png")) - device.dumpWindowHierarchy(File(directory, "device-failure.xml")) - } - } - private val activity = object : ExternalResource() { - override fun after() { scenario.close() } + private fun captureFailure(error: Throwable) { + println("Device failure: ${error.javaClass.simpleName}") + val directory = requireNotNull(context.getExternalFilesDir(null)) + device.takeScreenshot(File(directory, "device-failure.png")) + device.dumpWindowHierarchy(File(directory, "device-failure.xml")) } - private val finish = object : ExternalResource() { - override fun after() { cleanup() } + + private fun flushDiagnosticLog() { + // Test-only barrier after VPN shutdown; a busy/discarded barrier fails with a timeout. + val field = PersistentDiagnosticLog::class.java.getDeclaredField("executor").apply { isAccessible = true } + val executor = field.get(null) as ThreadPoolExecutor + executor.submit {}.get(5, TimeUnit.SECONDS) } - @get:Rule val rules: RuleChain = RuleChain.outerRule(platform).around(activity).around(finish).around(evidence) + + @get:Rule val rules: org.junit.rules.TestRule = DeviceTestEvidenceRule( + prepare = ::prepare, + cleanup = { if (::scenario.isInitialized) cleanup() }, + close = { if (::scenario.isInitialized) scenario.close() }, + evidence = ::captureFailure, + diagnostics = { + io { + PersistentDiagnosticLog.write("event=device_test stage=finish") + flushDiagnosticLog() + } + println("MegaProxy diagnostic tail:\n${PersistentDiagnosticLog.readTail(64 * 1024)}") + }, + ) } diff --git a/app/src/sharedTest/java/net/megaproxy487/DeviceTestEvidenceRule.kt b/app/src/sharedTest/java/net/megaproxy487/DeviceTestEvidenceRule.kt new file mode 100644 index 0000000..f4711fd --- /dev/null +++ b/app/src/sharedTest/java/net/megaproxy487/DeviceTestEvidenceRule.kt @@ -0,0 +1,40 @@ +package net.megaproxy487 + +import org.junit.AssumptionViolatedException +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.MultipleFailureException +import org.junit.runners.model.Statement + +/** Test-only lifecycle shared by instrumentation and plain JVM regression tests. */ +internal class DeviceTestEvidenceRule( + private val prepare: () -> Unit, + private val cleanup: () -> Unit, + private val close: () -> Unit, + private val evidence: (Throwable) -> Unit, + private val diagnostics: () -> Unit, +) : TestRule { + override fun apply(base: Statement, description: Description) = object : Statement() { + override fun evaluate() { + val errors = mutableListOf() + var captured = false + fun attempt(action: () -> Unit) { + try { + action() + } catch (error: Throwable) { + errors.add(error) + if (error !is AssumptionViolatedException && !captured) { + captured = true + try { evidence(error) } catch (captureError: Throwable) { errors.add(captureError) } + } + } + } + attempt(prepare) + if (errors.isEmpty()) attempt { base.evaluate() } + attempt(cleanup) + attempt(close) + attempt(diagnostics) + MultipleFailureException.assertEmpty(errors) + } + } +} diff --git a/app/src/test/java/net/megaproxy487/DeviceTestEvidenceRuleTest.kt b/app/src/test/java/net/megaproxy487/DeviceTestEvidenceRuleTest.kt new file mode 100644 index 0000000..c16af4b --- /dev/null +++ b/app/src/test/java/net/megaproxy487/DeviceTestEvidenceRuleTest.kt @@ -0,0 +1,60 @@ +package net.megaproxy487 + +import org.junit.Assert.* +import org.junit.AssumptionViolatedException +import org.junit.Test +import org.junit.runner.Description +import org.junit.runners.model.MultipleFailureException +import org.junit.runners.model.Statement + +class DeviceTestEvidenceRuleTest { + private fun run(events: MutableList, failures: Map = emptyMap()) { + fun step(name: String) { + events.add(name) + failures[name]?.let { throw it } + } + DeviceTestEvidenceRule( + prepare = { step("prepare") }, cleanup = { step("cleanup") }, + close = { step("close") }, evidence = { step("evidence") }, + diagnostics = { step("diagnostics") }, + ).apply(object : Statement() { + override fun evaluate() = step("body") + }, Description.EMPTY).evaluate() + } + + @Test fun successStillCollectsDiagnostics() { + val events = mutableListOf() + run(events) + assertEquals(listOf("prepare", "body", "cleanup", "close", "diagnostics"), events) + } + + @Test fun eachFailureIsCapturedBeforeTheNextPhaseAndStillRunsCleanup() { + for (phase in listOf("prepare", "body", "cleanup", "close", "diagnostics")) { + val events = mutableListOf() + val failure = IllegalStateException(phase) + val thrown = runCatching { run(events, mapOf(phase to failure)) }.exceptionOrNull() + assertSame(phase, failure, thrown) + val expected = mutableListOf("prepare", "body", "cleanup", "close", "diagnostics") + if (phase == "prepare") expected.remove("body") + expected.add(expected.indexOf(phase) + 1, "evidence") + assertEquals(phase, expected, events) + } + } + + @Test fun evidenceAndCleanupFailuresDoNotHideTheOriginalFailure() { + val events = mutableListOf() + val failures = listOf("body", "evidence", "cleanup", "close", "diagnostics") + .associateWith { IllegalStateException(it) } + val thrown = runCatching { run(events, failures) }.exceptionOrNull() + assertTrue(thrown is MultipleFailureException) + assertEquals(failures.values.toList(), (thrown as MultipleFailureException).failures) + assertEquals(1, events.count { it == "evidence" }) + } + + @Test fun skippedBodyStillCleansUpWithoutFailureEvidence() { + val events = mutableListOf() + val skipped = AssumptionViolatedException("unsupported API") + assertSame(skipped, runCatching { run(events, mapOf("body" to skipped)) }.exceptionOrNull()) + assertEquals(listOf("prepare", "body", "cleanup", "close", "diagnostics"), events) + } +} diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index 819df6c..452519d 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -59,6 +59,8 @@ physical network handover, OEM power policy, every Always-on/lockdown mode or re The disposable emulator disables public captive-portal probes, reconnects Wi-Fi on API 35 (the API 26 `svc wifi` client exits with 137) and waits for it to become the validated default network. Before starting VPN scenarios it must receive the local SSH fixture banner through `10.0.2.2`; failure saves network diagnostics and stops the suite without retrying tests. +Each case waits for the previous app process and activities to finish, then clears Logcat and writes a start marker. Success and failure outputs include a sanitized diagnostic tail (up to 64 KiB), collected after VPN cleanup. Setup, body and cleanup failures retain failure evidence; evidence collection cannot hide the original error. The local TCP/UDP fixture retries only port-allocation collisions, before starting containers or tests. + ## Release acceptance: smoke first, expand by risk Identify the exact candidate commit, version/ABI, APK SHA-256 and signer. Use full successful CI for diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index e524823..b2b43f7 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -60,6 +60,8 @@ instrumentation-процессом; Keystore seed/verify должны остав Одноразовый эмулятор отключает публичные проверки captive portal, переподключает Wi-Fi на API 35 (клиент `svc wifi` API 26 завершается с кодом 137) и ждёт, пока она станет подтверждённой сетью по умолчанию. До VPN-сценариев он должен получить баннер локального SSH-стенда через `10.0.2.2`; при отказе сохраняется диагностика сети, а набор останавливается без повторения тестов. +Перед каждым сценарием runner ждёт завершения предыдущего процесса и Activity, очищает Logcat и ставит начальную метку. Вывод успешных и упавших сценариев содержит обезличенный хвост диагностического журнала (до 64 KiB), собранный после остановки VPN. Ошибки подготовки, самого теста и cleanup сохраняют доказательства; ошибка их сбора не скрывает исходную. Локальная TCP/UDP-фикстура повторяет только выбор занятого порта, до запуска контейнеров и тестов. + ## Приёмка релиза: smoke, затем риски изменения Зафиксируйте точный commit, версию/ABI, SHA-256 и подпись APK. Нужен полный успешный CI кандидата; diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index b2e2842..432f6f4 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -4,6 +4,7 @@ import argparse import base64 import contextlib +import errno import os import re import secrets @@ -69,7 +70,7 @@ def require_success(output): raise RuntimeError("Instrumentation did not report exactly one successful test") -def finish_case(shell, results, label, case, suite, log_start): +def finish_case(shell, results, label, case, suite): error_message = None evidence = ( (("anr", ("dumpsys", "dropbox", "--print", "data_app_anr")),) @@ -77,7 +78,7 @@ def finish_case(shell, results, label, case, suite, log_start): else () ) for name, words in ( - ("logcat", ("logcat", "-d", "-v", "threadtime", "-T", log_start)), + ("logcat", ("logcat", "-d", "-v", "threadtime")), *evidence, ("force-stop", ("am", "force-stop", PACKAGE)), ): @@ -102,6 +103,46 @@ def finish_case(shell, results, label, case, suite, log_start): raise RuntimeError(error_message + "; see collected evidence") +def begin_case(shell, api, label): + shell("am", "force-stop", PACKAGE) + if api >= 33: + operation = "revoke" if "deniedNotifications" in label else "grant" + shell("pm", operation, PACKAGE, "android.permission.POST_NOTIFICATIONS") + deadline = time.monotonic() + 15 + while True: + process = shell("sh", "-c", f"pidof {PACKAGE} || true") + activities = shell("dumpsys", "activity", "activities") + if not process and not re.search( + r"ActivityRecord\{[^\n]* net\.megaproxy487(?:\.test)?/", activities + ): + break + if time.monotonic() >= deadline: + raise RuntimeError("Previous Android process or activities did not finish") + time.sleep(0.1) + # Start a fresh buffer only after the previous instrumentation process is gone. + # This works on API 26 too, without relying on date's fractional-second support. + shell("logcat", "-c") + shell("log", "-t", "MegaProxyDeviceTest", "BEGIN " + label) + + +def dual_protocol_port(): + # Only allocation collisions are retried, before containers or tests start. + for attempt in range(8): + with ( + socket.socket() as tcp, + socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp, + ): + tcp.bind(("127.0.0.1", 0)) + number = tcp.getsockname()[1] + try: + udp.bind(("127.0.0.1", number)) + except OSError as error: + if error.errno != errno.EADDRINUSE or attempt == 7: + raise + continue + return number + + class DirectDnsFixture(socketserver.BaseRequestHandler): def handle(self): query, sock = self.request @@ -138,13 +179,7 @@ def start(alias, port, server_image, *args): containers.append(container) options = ["docker", "run", "-d", "--name", container, "--network", name] if port == "8443/both": - with ( - socket.socket() as tcp, - socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp, - ): - tcp.bind(("127.0.0.1", 0)) - number = tcp.getsockname()[1] - udp.bind(("127.0.0.1", number)) + number = dual_protocol_port() options += [ "-p", f"127.0.0.1:{number}:8443/tcp", @@ -497,29 +532,8 @@ def shell(*words): name=method + ("." + phase if phase else ""), ) started = time.monotonic() - log_start = shell("date", "+%m-%d %H:%M:%S.000") try: - shell("am", "force-stop", PACKAGE) - # Test setup resets ConfigStore itself; clearing app data here can - # asynchronously remove an old task and kill the next test process. - if args.api >= 33: - operation = "revoke" if "deniedNotifications" in test else "grant" - shell( - "pm", - operation, - PACKAGE, - "android.permission.POST_NOTIFICATIONS", - ) - # Force-stop/task removal can finish asynchronously and kill a newly - # started instrumentation process. Wait for actual Activity removal. - deadline = time.monotonic() + 15 - while re.search( - r"ActivityRecord\{[^\n]* net\.megaproxy487(?:\.test)?/", - shell("dumpsys", "activity", "activities"), - ): - if time.monotonic() >= deadline: - raise RuntimeError("Previous Android activities did not finish") - time.sleep(0.1) + begin_case(shell, args.api, label) options = [ "am", "instrument", @@ -562,7 +576,7 @@ def shell(*words): print(f"Failure {extension} evidence unavailable", flush=True) finally: case.set("time", f"{time.monotonic() - started:.3f}") - finish_case(shell, results, label, case, suite, log_start) + finish_case(shell, results, label, case, suite) return bool(failures) diff --git a/scripts/tests/test_android_device_tests.py b/scripts/tests/test_android_device_tests.py index a3fc1f5..4b2e24b 100644 --- a/scripts/tests/test_android_device_tests.py +++ b/scripts/tests/test_android_device_tests.py @@ -1,3 +1,4 @@ +import errno import re import subprocess import sys @@ -126,7 +127,7 @@ def test_logcat_failure_preserves_evidence_and_failed_report(self): 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") + m.finish_case(shell, results, "test", case, suite) report = ET.parse(results / "junit.xml").getroot() self.assertEqual(report.get("tests"), "1") self.assertEqual(report.get("failures"), "1") @@ -145,7 +146,7 @@ def test_failed_case_collects_anr_stacks_before_stopping_app(self): shell = Mock(side_effect=["logcat", "ANR thread stacks", ""]) with tempfile.TemporaryDirectory() as directory: results = Path(directory) - m.finish_case(shell, results, "test", case, suite, "10-22 08:18:00.000") + m.finish_case(shell, results, "test", case, suite) self.assertEqual( (results / "test-anr.txt").read_text(), "ANR thread stacks" ) @@ -158,6 +159,79 @@ def test_failed_case_collects_anr_stacks_before_stopping_app(self): ) self.assertEqual(suite.get("failures"), "1") + def test_case_waits_for_previous_process_and_activity_before_clearing_logcat(self): + responses = iter( + [ + "321", + "", + "", + "ActivityRecord{abc net.megaproxy487/.MainActivity}", + "", + "", + ] + ) + + def response(*args): + if args[0] in ("sh", "dumpsys"): + return next(responses) + return "" + + shell = Mock(side_effect=response) + with patch.object(m.time, "sleep"): + m.begin_case(shell, 26, "case") + self.assertEqual( + shell.call_args_list[-2:], + [ + call("logcat", "-c"), + call("log", "-t", "MegaProxyDeviceTest", "BEGIN case"), + ], + ) + self.assertEqual( + shell.call_args_list.count(call("dumpsys", "activity", "activities")), 3 + ) + + def test_previous_process_timeout_does_not_clear_evidence(self): + shell = Mock(return_value="still running") + with patch.object(m.time, "monotonic", side_effect=[0, 16]): + with self.assertRaisesRegex(RuntimeError, "did not finish"): + m.begin_case(shell, 35, "deniedNotifications") + self.assertNotIn(call("logcat", "-c"), shell.call_args_list) + shell.assert_any_call( + "pm", "revoke", m.PACKAGE, "android.permission.POST_NOTIFICATIONS" + ) + + def test_dual_port_retries_only_address_collisions_and_closes_both_sockets(self): + for error, attempts in [(errno.EADDRINUSE, 2), (errno.EACCES, 1)]: + with self.subTest(error=error): + tcp, udp = Mock(), Mock() + tcp.getsockname.return_value = ("127.0.0.1", 12345) + udp.bind.side_effect = [OSError(error, "bind"), None] + sockets = [Mock(), Mock(), Mock(), Mock()] + for index, sock in enumerate(sockets): + sock.__enter__ = Mock(return_value=tcp if index % 2 == 0 else udp) + sock.__exit__ = Mock(return_value=False) + with patch.object(m.socket, "socket", side_effect=sockets): + if error == errno.EADDRINUSE: + self.assertEqual(m.dual_protocol_port(), 12345) + else: + with self.assertRaises(OSError): + m.dual_protocol_port() + for sock in sockets[: attempts * 2]: + sock.__exit__.assert_called_once() + + def test_dual_port_collision_retries_are_bounded(self): + tcp, udp = Mock(), Mock() + tcp.getsockname.return_value = ("127.0.0.1", 12345) + udp.bind.side_effect = OSError(errno.EADDRINUSE, "bind") + sockets = [Mock(), Mock()] * 8 + for index, sock in enumerate(sockets[:2]): + sock.__enter__ = Mock(return_value=tcp if index == 0 else udp) + sock.__exit__ = Mock(return_value=False) + with patch.object(m.socket, "socket", side_effect=sockets): + with self.assertRaises(OSError): + m.dual_protocol_port() + self.assertEqual(udp.bind.call_count, 8) + def test_adb_success_is_not_enough(self): for output in ( "INSTRUMENTATION_CODE: 0", From 2b7f73b570d60fa5277cf66e55658365a90e83ef Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 27 Oct 2026 22:27:47 +0300 Subject: [PATCH 2/3] Use precise Logcat markers without clearing API 26 buffers --- docs/en/release-testing.md | 2 +- docs/ru/release-testing.md | 2 +- scripts/android_device_tests.py | 39 +++++++++++++++++----- scripts/tests/test_android_device_tests.py | 32 +++++++++++++----- 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index 452519d..0fbd2a2 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -59,7 +59,7 @@ physical network handover, OEM power policy, every Always-on/lockdown mode or re The disposable emulator disables public captive-portal probes, reconnects Wi-Fi on API 35 (the API 26 `svc wifi` client exits with 137) and waits for it to become the validated default network. Before starting VPN scenarios it must receive the local SSH fixture banner through `10.0.2.2`; failure saves network diagnostics and stops the suite without retrying tests. -Each case waits for the previous app process and activities to finish, then clears Logcat and writes a start marker. Success and failure outputs include a sanitized diagnostic tail (up to 64 KiB), collected after VPN cleanup. Setup, body and cleanup failures retain failure evidence; evidence collection cannot hide the original error. The local TCP/UDP fixture retries only port-allocation collisions, before starting containers or tests. +Each case waits for the previous app process and activities to finish, then reads the precise device timestamp of its unique Logcat start marker, without clearing global buffers. Success and failure outputs include a sanitized diagnostic tail (up to 64 KiB), collected after VPN cleanup. Setup, body and cleanup failures retain failure evidence; evidence collection cannot hide the original error. The local TCP/UDP fixture retries only port-allocation collisions, before starting containers or tests. ## Release acceptance: smoke first, expand by risk diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index b2b43f7..ca1f05d 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -60,7 +60,7 @@ instrumentation-процессом; Keystore seed/verify должны остав Одноразовый эмулятор отключает публичные проверки captive portal, переподключает Wi-Fi на API 35 (клиент `svc wifi` API 26 завершается с кодом 137) и ждёт, пока она станет подтверждённой сетью по умолчанию. До VPN-сценариев он должен получить баннер локального SSH-стенда через `10.0.2.2`; при отказе сохраняется диагностика сети, а набор останавливается без повторения тестов. -Перед каждым сценарием runner ждёт завершения предыдущего процесса и Activity, очищает Logcat и ставит начальную метку. Вывод успешных и упавших сценариев содержит обезличенный хвост диагностического журнала (до 64 KiB), собранный после остановки VPN. Ошибки подготовки, самого теста и cleanup сохраняют доказательства; ошибка их сбора не скрывает исходную. Локальная TCP/UDP-фикстура повторяет только выбор занятого порта, до запуска контейнеров и тестов. +Перед каждым сценарием runner ждёт завершения предыдущего процесса и Activity, читает точное время собственной уникальной метки в Logcat, не очищая общие буферы. Вывод успешных и упавших сценариев содержит обезличенный хвост диагностического журнала (до 64 KiB), собранный после остановки VPN. Ошибки подготовки, самого теста и cleanup сохраняют доказательства; ошибка их сбора не скрывает исходную. Локальная TCP/UDP-фикстура повторяет только выбор занятого порта, до запуска контейнеров и тестов. ## Приёмка релиза: smoke, затем риски изменения diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 432f6f4..92554b2 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -70,7 +70,7 @@ def require_success(output): raise RuntimeError("Instrumentation did not report exactly one successful test") -def finish_case(shell, results, label, case, suite): +def finish_case(shell, results, label, case, suite, log_start=None): error_message = None evidence = ( (("anr", ("dumpsys", "dropbox", "--print", "data_app_anr")),) @@ -78,7 +78,16 @@ def finish_case(shell, results, label, case, suite): else () ) for name, words in ( - ("logcat", ("logcat", "-d", "-v", "threadtime")), + ( + "logcat", + ( + "logcat", + "-d", + "-v", + "threadtime", + *(("-T", log_start) if log_start else ()), + ), + ), *evidence, ("force-stop", ("am", "force-stop", PACKAGE)), ): @@ -119,10 +128,23 @@ def begin_case(shell, api, label): if time.monotonic() >= deadline: raise RuntimeError("Previous Android process or activities did not finish") time.sleep(0.1) - # Start a fresh buffer only after the previous instrumentation process is gone. - # This works on API 26 too, without relying on date's fractional-second support. - shell("logcat", "-c") - shell("log", "-t", "MegaProxyDeviceTest", "BEGIN " + label) + # API 26 can fail logcat -c while logd is busy. Read our marker's precise + # device timestamp instead; date's rounded seconds include the previous case. + marker = "BEGIN " + label + " " + uuid.uuid4().hex + shell("log", "-p", "i", "-t", "MegaProxyDeviceTest", marker) + records = shell( + "logcat", "-d", "-v", "threadtime", "-s", "MegaProxyDeviceTest:I", "*:S" + ) + match = re.search( + r"^(\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}).* MegaProxyDeviceTest: " + + re.escape(marker) + + r"$", + records, + re.MULTILINE, + ) + if not match: + raise RuntimeError("Device Logcat start marker is missing") + return match.group(1) def dual_protocol_port(): @@ -532,8 +554,9 @@ def shell(*words): name=method + ("." + phase if phase else ""), ) started = time.monotonic() + log_start = None try: - begin_case(shell, args.api, label) + log_start = begin_case(shell, args.api, label) options = [ "am", "instrument", @@ -576,7 +599,7 @@ def shell(*words): print(f"Failure {extension} evidence unavailable", flush=True) finally: case.set("time", f"{time.monotonic() - started:.3f}") - finish_case(shell, results, label, case, suite) + 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 4b2e24b..b867cc4 100644 --- a/scripts/tests/test_android_device_tests.py +++ b/scripts/tests/test_android_device_tests.py @@ -159,7 +159,7 @@ def test_failed_case_collects_anr_stacks_before_stopping_app(self): ) self.assertEqual(suite.get("failures"), "1") - def test_case_waits_for_previous_process_and_activity_before_clearing_logcat(self): + def test_case_waits_for_previous_process_and_uses_precise_log_marker(self): responses = iter( [ "321", @@ -170,25 +170,39 @@ def test_case_waits_for_previous_process_and_activity_before_clearing_logcat(sel "", ] ) + marker = None def response(*args): + nonlocal marker if args[0] in ("sh", "dumpsys"): return next(responses) + if args[0] == "log": + marker = args[-1] + if args[0] == "logcat": + return "10-27 19:18:58.997 123 123 I MegaProxyDeviceTest: " + marker return "" shell = Mock(side_effect=response) with patch.object(m.time, "sleep"): - m.begin_case(shell, 26, "case") - self.assertEqual( - shell.call_args_list[-2:], - [ - call("logcat", "-c"), - call("log", "-t", "MegaProxyDeviceTest", "BEGIN case"), - ], - ) + self.assertEqual(m.begin_case(shell, 26, "case"), "10-27 19:18:58.997") self.assertEqual( shell.call_args_list.count(call("dumpsys", "activity", "activities")), 3 ) + self.assertNotIn(call("logcat", "-c"), shell.call_args_list) + suite = ET.Element("testsuite") + case = ET.SubElement(suite, "testcase", time="1.0") + evidence = Mock(return_value="") + with tempfile.TemporaryDirectory() as directory: + m.finish_case( + evidence, Path(directory), "case", case, suite, "10-27 19:18:58.997" + ) + evidence.assert_any_call( + "logcat", "-d", "-v", "threadtime", "-T", "10-27 19:18:58.997" + ) + + def test_missing_start_marker_fails_before_instrumentation(self): + with self.assertRaisesRegex(RuntimeError, "start marker is missing"): + m.begin_case(Mock(return_value=""), 26, "case") def test_previous_process_timeout_does_not_clear_evidence(self): shell = Mock(return_value="still running") From 7a1c47f22621d6b9c1ee32455303d9db2d3414a4 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 27 Oct 2026 22:38:35 +0300 Subject: [PATCH 3/3] Preserve device evidence when Logcat contains invalid UTF-8 --- scripts/android_device_tests.py | 14 ++++++-- scripts/tests/test_android_device_tests.py | 40 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 92554b2..e80adc5 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -54,9 +54,9 @@ ) -def command(*args, timeout=300): +def command(*args, timeout=300, errors="strict"): return subprocess.check_output( - args, text=True, stderr=subprocess.STDOUT, timeout=timeout + args, encoding="utf-8", errors=errors, stderr=subprocess.STDOUT, timeout=timeout ).strip() @@ -482,7 +482,15 @@ def main(): adb = [str(adb_path), "-s", serial] def shell(*words): - return command(*adb, "shell", shlex.join(words), timeout=180) + # Android logs may contain arbitrary bytes; never let decoding skip evidence + # or cleanup. Keep fixture keys/configuration and other commands strict. + return command( + *adb, + "shell", + shlex.join(words), + timeout=180, + errors="replace" if words[0] == "logcat" else "strict", + ) if int(shell("getprop", "ro.build.version.sdk")) != args.api: parser.error("Connected emulator API does not match --api") diff --git a/scripts/tests/test_android_device_tests.py b/scripts/tests/test_android_device_tests.py index b867cc4..d1210cd 100644 --- a/scripts/tests/test_android_device_tests.py +++ b/scripts/tests/test_android_device_tests.py @@ -113,6 +113,46 @@ def test_emulator_rejects_an_unvalidated_default_network(self): with self.assertRaisesRegex(RuntimeError, "no validated default Wi-Fi"): m.prepare_emulator(Mock(return_value=state), 26) + def test_invalid_utf8_logs_preserve_evidence_cleanup_and_test_verdict(self): + child = ( + sys.executable, + "-c", + r"import os; os.write(1, b'event \xc0 FATAL EXCEPTION\n')", + ) + # Binary fixture/configuration output must still fail strict decoding. + with self.assertRaises(UnicodeDecodeError): + m.command(*child) + for failed in (False, True): + with ( + self.subTest(failed=failed), + tempfile.TemporaryDirectory() as directory, + ): + suite = ET.Element("testsuite") + case = ET.SubElement(suite, "testcase", time="1.0") + if failed: + ET.SubElement(case, "failure", message="original assertion") + shell = Mock( + side_effect=lambda *words: ( + m.command(*child, errors="replace") + if words[0] == "logcat" + else "" + ) + ) + results = Path(directory) + m.finish_case(shell, results, "test", case, suite) + self.assertIn( + "event \ufffd FATAL EXCEPTION", + (results / "test-logcat.txt").read_text(), + ) + shell.assert_any_call("am", "force-stop", m.PACKAGE) + report = ET.parse(results / "junit.xml").getroot() + self.assertEqual(report.get("tests"), "1") + self.assertEqual(report.get("failures"), "1" if failed else "0") + if failed: + self.assertEqual( + report[0].find("failure").get("message"), "original assertion" + ) + def test_logcat_failure_preserves_evidence_and_failed_report(self): suite = ET.Element("testsuite") case = ET.SubElement(suite, "testcase", time="1.0")