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..0fbd2a2 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 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 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..ca1f05d 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..e80adc5 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 @@ -53,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() @@ -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, log_start=None): error_message = None evidence = ( (("anr", ("dumpsys", "dropbox", "--print", "data_app_anr")),) @@ -77,7 +78,16 @@ 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", + *(("-T", log_start) if log_start else ()), + ), + ), *evidence, ("force-stop", ("am", "force-stop", PACKAGE)), ): @@ -102,6 +112,59 @@ 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) + # 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(): + # 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 +201,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", @@ -425,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") @@ -497,29 +562,9 @@ def shell(*words): name=method + ("." + phase if phase else ""), ) started = time.monotonic() - log_start = shell("date", "+%m-%d %H:%M:%S.000") + log_start = None 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) + log_start = begin_case(shell, args.api, label) options = [ "am", "instrument", diff --git a/scripts/tests/test_android_device_tests.py b/scripts/tests/test_android_device_tests.py index a3fc1f5..d1210cd 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 @@ -112,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") @@ -126,7 +167,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 +186,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 +199,93 @@ 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_uses_precise_log_marker(self): + responses = iter( + [ + "321", + "", + "", + "ActivityRecord{abc net.megaproxy487/.MainActivity}", + "", + "", + ] + ) + 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"): + 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") + 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",