feat: release 1.4.0 with isolated upgrades and device discovery
This commit is contained in:
1 parent
a6a3ee2eb0
commit
df66897ff4
41 files changed
+1564
-126
No files matched your search
@@ -0,0 +1,290 @@
|
||||
"""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"]
|
||||
Reference in new issue
Block a user