feat: release 1.4.0 with isolated upgrades and device discovery

This commit is contained in:
chenmingxuan committed 2026-10-09 15:46:28 +08:00
1 parent a6a3ee2eb0
commit df66897ff4
41 files changed
+1564 -126

No files matched your search

+290
View File
@@ -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"]