"""TCP discovery tests never touch real processes, ports, devices or user state.""" import importlib import json import struct import subprocess from types import SimpleNamespace from unittest.mock import Mock import pytest from artemis.config import settings from artemis.runtime.adb_endpoint import AdbEndpoint module = importlib.import_module("artemis.runtime.adb_discovery") @pytest.fixture def discovery(tmp_path, monkeypatch): instance = module.AdbDeviceDiscovery() monkeypatch.setattr(instance, "_directory", lambda: tmp_path) monkeypatch.setattr(module, "emulator_listeners", lambda: []) monkeypatch.setattr( module, "current_adb_endpoint", lambda: AdbEndpoint.create("127.0.0.1", 5037) ) monkeypatch.setattr(settings, "ARTEMIS_ADB_AUTO_CONNECT", True) monkeypatch.setattr(settings, "ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS", 5) monkeypatch.setattr(settings, "ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS", 10) return instance def fake_adb(monkeypatch, *, ready="", failures=()): calls = [] connected = [] def run(self, arguments, **kwargs): calls.append(arguments) assert 0 < kwargs["timeout"] <= 5 if arguments == ["devices"]: return subprocess.CompletedProcess( arguments, 0, "List of devices attached\n" + ready + "".join(f"{target} device\n" for target in connected), "", ) target = arguments[1] if target in failures: raise subprocess.TimeoutExpired(arguments, kwargs["timeout"]) connected.append(target) return subprocess.CompletedProcess(arguments, 0, f"connected to {target}", "") monkeypatch.setattr(module.AdbSession, "run", run) return calls @pytest.mark.parametrize( "name", [ "MuMuNxDevice.exe", "NemuHeadless.exe", "LdVBoxHeadless.exe", "NoxVMHandle.exe", "MEmuHeadless.exe", "HD-Player.exe", ], ) def test_supported_emulator_listener_ownership(monkeypatch, name): monkeypatch.setattr( module.psutil, "process_iter", lambda attrs: [ SimpleNamespace(info={"pid": 4, "name": name, "exe": "vendor.exe"}), SimpleNamespace(info={"pid": 5, "name": "other.exe"}), ], ) monkeypatch.setattr( module.psutil, "net_connections", lambda kind: [ SimpleNamespace(pid=pid, status="LISTEN", laddr=SimpleNamespace(ip=ip, port=port)) for pid, ip, port in [ (4, "0.0.0.0", 16384), (4, "127.0.0.1", 7555), (4, "192.0.2.1", 5555), (5, "0.0.0.0", 16385), ] ], ) assert module.emulator_listeners() == [["127.0.0.1:16384", "127.0.0.1:7555"]] def test_generic_virtualbox_is_not_an_android_emulator(monkeypatch): monkeypatch.setattr( module.psutil, "process_iter", lambda attrs: [ SimpleNamespace( info={"pid": 4, "name": "VBoxHeadless.exe", "exe": "VirtualBox/VBoxHeadless.exe"} ) ], ) monkeypatch.setattr( module.psutil, "net_connections", lambda kind: pytest.fail("No known emulator; do not scan listeners"), ) assert module.emulator_listeners() == [] @pytest.mark.parametrize("kind,valid", [(b"CNXN", True), (b"AUTH", True), (b"HTTP", False)]) def test_adbd_protocol_handles_fragmented_headers(monkeypatch, kind, valid): command = int.from_bytes(kind, "little") header = struct.pack("<6I", command, 1, 4096, 0, 0, command ^ 0xFFFFFFFF) stream = Mock() stream.__enter__ = Mock(return_value=stream) stream.__exit__ = Mock(return_value=False) stream.recv.side_effect = [header[:8], header[8:]] monkeypatch.setattr(module.socket, "create_connection", lambda *args, **kwargs: stream) assert module.is_adbd("127.0.0.1:16384", 0.3) is valid assert stream.sendall.call_args.args[0][:4] == b"CNXN" def test_discovery_connects_new_instance_without_duplicate_aliases(discovery, monkeypatch): calls = fake_adb(monkeypatch) monkeypatch.setattr( module, "emulator_listeners", lambda: [["127.0.0.1:16384", "127.0.0.1:7555"], ["127.0.0.1:16416"]], ) monkeypatch.setattr(module, "is_adbd", lambda *args: True) results = discovery.refresh("adb") assert [r["target"] for r in results] == ["127.0.0.1:16384", "127.0.0.1:16416"] discovery.refresh("adb") assert len(calls) == 5 # cached: no subprocesses on repeated polling discovery.refresh("adb", force=True) assert len(calls) == 6 # force enumerates; connected instances are not reconnected def test_ready_and_unauthorized_emulators_are_not_probed(discovery, monkeypatch): calls = fake_adb(monkeypatch, ready="127.0.0.1:7555 device\n127.0.0.1:16416 unauthorized\n") monkeypatch.setattr( module, "emulator_listeners", lambda: [["127.0.0.1:16384", "127.0.0.1:7555"], ["127.0.0.1:16416"]], ) monkeypatch.setattr( module, "is_adbd", lambda *args: pytest.fail("Do not touch an existing transport") ) assert discovery.refresh("adb") == [] assert calls == [["devices"]] def test_non_adb_port_is_not_connected(discovery, monkeypatch): calls = fake_adb(monkeypatch) monkeypatch.setattr( module, "emulator_listeners", lambda: [["127.0.0.1:23579", "127.0.0.1:16384"]] ) monkeypatch.setattr(module, "is_adbd", lambda target, timeout: target.endswith(":16384")) assert discovery.refresh("adb")[0]["success"] is True assert calls == [["devices"], ["connect", "127.0.0.1:16384"], ["devices"]] @pytest.mark.parametrize("port", [5038, 5040]) def test_forwarded_adb_never_discovers_local_devices(discovery, monkeypatch, port): monkeypatch.setattr( module, "current_adb_endpoint", lambda: AdbEndpoint.create("127.0.0.1", port) ) monkeypatch.setattr( module.AdbSession, "run", lambda *args, **kwargs: pytest.fail("Remote endpoint must stay untouched"), ) assert discovery.refresh("adb", force=True) == [] def test_disabled_discovery_and_concurrent_call_do_not_connect(discovery, monkeypatch): monkeypatch.setattr(settings, "ARTEMIS_ADB_AUTO_CONNECT", False) monkeypatch.setattr( module.AdbSession, "run", lambda *args, **kwargs: pytest.fail("Must not spawn") ) assert discovery.refresh("adb") == [] monkeypatch.setattr(settings, "ARTEMIS_ADB_AUTO_CONNECT", True) with discovery._gate: assert discovery.refresh("adb") == [] def test_remembered_phone_survives_process_restart_and_offline_peer( discovery, monkeypatch, tmp_path ): assert discovery.remember("192.0.2.10:5555") assert discovery.remember("192.0.2.11:5555") second = module.AdbDeviceDiscovery() monkeypatch.setattr(second, "_directory", lambda: tmp_path) calls = fake_adb(monkeypatch, failures={"192.0.2.10:5555"}) results = second.refresh("adb") assert len(results) == 2 assert any(r["success"] and r["target"] == "192.0.2.11:5555" for r in results) assert len(calls) == 4 def test_total_budget_stops_unlimited_connection_attempts(discovery, monkeypatch): clock = [0.0] monkeypatch.setattr(module.time, "monotonic", lambda: clock[0]) monkeypatch.setattr( module, "emulator_listeners", lambda: [[f"127.0.0.1:{16384 + i}"] for i in range(100)] ) def probe(*args): clock[0] += 1 return True monkeypatch.setattr(module, "is_adbd", probe) calls = fake_adb(monkeypatch) discovery.refresh("adb") assert len(calls) == 9 # listing plus four connects/readiness checks within 5s def test_corrupt_persistence_and_missing_process_permissions_are_nonfatal( discovery, monkeypatch, tmp_path ): (tmp_path / "broken.json").write_text(json.dumps({"target": "bad"})) assert discovery._remembered() == [] fake_adb(monkeypatch) monkeypatch.setattr( module, "emulator_listeners", Mock(side_effect=module.psutil.AccessDenied()) ) assert discovery.refresh("adb")[0]["success"] is False def test_listener_permission_failure_still_reconnects_saved_phone(discovery, monkeypatch): assert discovery.remember("192.0.2.10:5555") fake_adb(monkeypatch) monkeypatch.setattr( module, "emulator_listeners", Mock(side_effect=module.psutil.AccessDenied()) ) results = discovery.refresh("adb") assert results[-1]["target"] == "192.0.2.10:5555" assert results[-1]["success"] is True def test_localhost_alias_does_not_create_duplicate_transport(discovery, monkeypatch): assert discovery.remember("localhost:16384") calls = fake_adb(monkeypatch, ready="127.0.0.1:16384 device\n") assert discovery.refresh("adb") == [] assert calls == [["devices"]] def test_connect_waits_until_transport_is_visible(monkeypatch): clock = [0.0] monkeypatch.setattr(module.time, "monotonic", lambda: clock[0]) monkeypatch.setattr( module.time, "sleep", lambda seconds: clock.__setitem__(0, clock[0] + seconds) ) responses = iter( ["List of devices attached\n", "127.0.0.1:16384 offline\n", "127.0.0.1:16384 device\n"] ) session = Mock() session.run.side_effect = lambda *args, **kwargs: subprocess.CompletedProcess( [], 0, next(responses), "" ) assert module.AdbDeviceDiscovery._wait_for_transport(session, "localhost:16384", 1) == "device" assert session.run.call_count == 3 def test_transport_settling_is_bounded(monkeypatch): clock = [0.0] monkeypatch.setattr(module.time, "monotonic", lambda: clock[0]) monkeypatch.setattr( module.time, "sleep", lambda seconds: clock.__setitem__(0, clock[0] + seconds) ) session = Mock() session.run.return_value = subprocess.CompletedProcess([], 0, "List of devices attached\n", "") assert module.AdbDeviceDiscovery._wait_for_transport(session, "127.0.0.1:16384", 0.2) is None assert clock[0] == 0.2 def test_pending_transport_is_not_reported_as_failed_or_connected(discovery, monkeypatch): fake_adb(monkeypatch) monkeypatch.setattr( module, "emulator_listeners", lambda: [["127.0.0.1:16384", "127.0.0.1:7555"]] ) monkeypatch.setattr(module, "is_adbd", lambda *args: True) monkeypatch.setattr(discovery, "_wait_for_transport", lambda *args: None) results = discovery.refresh("adb") assert len(results) == 1 assert results[0]["pending"] is True assert results[0]["success"] is False assert "等待设备就绪" in results[0]["message"]