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
@@ -26,6 +26,20 @@ import pytest
|
||||
from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_host_adb_discovery(monkeypatch, request):
|
||||
"""Deterministic tests must never connect to host emulators or remembered phones."""
|
||||
from artemis.runtime.adb_discovery import adb_device_discovery
|
||||
|
||||
if any(
|
||||
request.node.get_closest_marker(name)
|
||||
for name in ("android", "integration", "e2e", "manual")
|
||||
):
|
||||
return
|
||||
monkeypatch.setattr(adb_device_discovery, "refresh", lambda *args, **kwargs: [])
|
||||
monkeypatch.setattr(adb_device_discovery, "remember", lambda *args: True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_driver():
|
||||
"""Provide an isolated mock mobile driver."""
|
||||
|
||||
@@ -310,6 +310,67 @@ async def test_remote_endpoint_blocks_local_wireless_connection(monkeypatch):
|
||||
assert "Switch to local ADB" in result["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_reconnects_tcp_devices_after_server_started(monkeypatch):
|
||||
from artemis.runtime.adb_discovery import adb_device_discovery
|
||||
|
||||
monkeypatch.setattr(
|
||||
adb_server_connection, "current_endpoint", lambda: AdbServerEndpoint("127.0.0.1", 5037)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"artemis.core.diagnostics.adb_keys.inspect_adb_keys",
|
||||
lambda: type("Keys", (), {"is_corrupted": False})(),
|
||||
)
|
||||
events = []
|
||||
|
||||
def run(args, **kwargs):
|
||||
events.append(args[-1])
|
||||
return subprocess.CompletedProcess(args, 0, "", "")
|
||||
|
||||
def reconnect(*args, **kwargs):
|
||||
events.append("reconnect")
|
||||
assert kwargs["force"] is True
|
||||
return [{"target": "127.0.0.1:16384", "success": True}]
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", run)
|
||||
monkeypatch.setattr(adb_device_discovery, "refresh", reconnect)
|
||||
result = await ReadinessEngine().restart_adb_server()
|
||||
assert events == ["kill-server", "start-server", "reconnect"]
|
||||
assert result["automatic_connections"][0]["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_tcp_connection_remembers_success_only(monkeypatch):
|
||||
from unittest.mock import Mock
|
||||
from artemis.runtime.adb_discovery import adb_device_discovery
|
||||
|
||||
monkeypatch.setattr(
|
||||
adb_server_connection, "current_endpoint", lambda: AdbServerEndpoint("127.0.0.1", 5037)
|
||||
)
|
||||
remember = Mock(return_value=True)
|
||||
monkeypatch.setattr(adb_device_discovery, "remember", remember)
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda args, **kwargs: subprocess.CompletedProcess(
|
||||
args, 0, "connected to 192.0.2.10:5555", ""
|
||||
),
|
||||
)
|
||||
result = await ReadinessEngine().connect_wireless_adb("192.0.2.10", 5555)
|
||||
assert result["remembered"] is True
|
||||
remember.assert_called_once_with("192.0.2.10:5555")
|
||||
remember.reset_mock()
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda args, **kwargs: subprocess.CompletedProcess(args, 1, "", "connection refused"),
|
||||
)
|
||||
result = await ReadinessEngine().connect_wireless_adb("192.0.2.11", 5555)
|
||||
assert result["success"] is False
|
||||
assert result["remembered"] is False
|
||||
remember.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_endpoint_blocks_local_emulator_launch(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Exercise CMD parsing and Windows PowerShell 5.1 without installing anything."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not shutil.which("cmd.exe") or not shutil.which("powershell.exe"),
|
||||
reason="Windows CMD and Windows PowerShell required",
|
||||
)
|
||||
|
||||
|
||||
def copy_launchers(root: Path, newline: bytes) -> None:
|
||||
root.mkdir()
|
||||
(root / "scripts").mkdir()
|
||||
for name in ("start.bat", "start-cn.bat"):
|
||||
content = (ROOT / name).read_bytes().replace(b"\r\n", b"\n")
|
||||
# ASCII batch wrappers remain safe even if a zip/editor changes EOLs.
|
||||
content.decode("ascii")
|
||||
(root / name).write_bytes(content.replace(b"\n", newline))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("newline", [b"\r\n", b"\n"])
|
||||
@pytest.mark.parametrize("exit_code", [0, 7])
|
||||
def test_launch_forwards_options_and_exit_code_without_cmd_garbling(tmp_path, newline, exit_code):
|
||||
root = tmp_path / "source with spaces"
|
||||
copy_launchers(root, newline)
|
||||
(root / "scripts" / "start.ps1").write_text(
|
||||
"""param([string]$Mirror, [int]$Port, [switch]$NoOpen, [switch]$SkipUpdate, [switch]$SkipMcpSync)
|
||||
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||
Write-Host '镜像地址和下载超时可在 config\\downloads.json 中调整。'
|
||||
Write-Host ('ARGS=' + (@{mirror=$Mirror;port=$Port;no_open=[bool]$NoOpen;skip_update=[bool]$SkipUpdate;skip_mcp=[bool]$SkipMcpSync;language=$env:ARTEMIS_LAUNCH_LANG} | ConvertTo-Json -Compress))
|
||||
exit ([int]$env:LAUNCH_TEST_EXIT)
|
||||
""",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"cmd.exe",
|
||||
"/d",
|
||||
"/c",
|
||||
"start-cn.bat",
|
||||
"-Port",
|
||||
"8123",
|
||||
"-NoOpen",
|
||||
"-SkipUpdate",
|
||||
"-SkipMcpSync",
|
||||
],
|
||||
cwd=root,
|
||||
env={**os.environ, "ARTEMIS_NO_PAUSE": "1", "LAUNCH_TEST_EXIT": str(exit_code)},
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == exit_code, result.stdout + result.stderr
|
||||
assert result.stderr == ""
|
||||
assert "镜像地址和下载超时可在" in result.stdout
|
||||
args = json.loads(
|
||||
next(
|
||||
line.removeprefix("ARGS=")
|
||||
for line in result.stdout.splitlines()
|
||||
if line.startswith("ARGS=")
|
||||
)
|
||||
)
|
||||
assert args == {
|
||||
"mirror": "China",
|
||||
"port": 8123,
|
||||
"no_open": True,
|
||||
"skip_update": True,
|
||||
"skip_mcp": True,
|
||||
"language": "zh-CN",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("newline", [b"\r\n", b"\n"])
|
||||
@pytest.mark.parametrize("help_flag", ["-Help", "--help"])
|
||||
def test_actual_chinese_help_has_no_encoding_error_or_bootstrap(tmp_path, newline, help_flag):
|
||||
root = tmp_path / "source with spaces"
|
||||
copy_launchers(root, newline)
|
||||
shutil.copyfile(ROOT / "scripts" / "start.ps1", root / "scripts" / "start.ps1")
|
||||
assert (root / "scripts" / "start.ps1").read_bytes().startswith(b"\xef\xbb\xbf")
|
||||
result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", "start-cn.bat", help_flag],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert result.stderr == ""
|
||||
assert "用法:start-cn.bat [选项]" in result.stdout
|
||||
assert "仅启动网页,跳过 Codex MCP" in result.stdout
|
||||
assert "国内镜像设置:config\\downloads.json" in result.stdout
|
||||
assert "�" not in result.stdout
|
||||
assert not (root / ".venv").exists()
|
||||
|
||||
|
||||
def test_batch_files_are_ascii_and_powershell_text_has_bom():
|
||||
for name in ("start.bat", "start-cn.bat"):
|
||||
(ROOT / name).read_bytes().decode("ascii")
|
||||
assert (ROOT / "scripts" / "start.ps1").read_bytes().startswith(b"\xef\xbb\xbf")
|
||||
@@ -53,18 +53,18 @@ def repos(tmp_path):
|
||||
|
||||
def test_fast_forward_update_preserves_env_and_is_repeatable(repos):
|
||||
installed, author = repos
|
||||
assert update.update_source(installed, port=1) == 10
|
||||
assert update.update_source(installed) == 10
|
||||
assert (installed / "source.txt").read_text() == "version2"
|
||||
assert (installed / ".env").read_text() == "secret stays local"
|
||||
assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
|
||||
assert update.update_source(installed, port=1) == 0
|
||||
assert update.update_source(installed) == 0
|
||||
|
||||
|
||||
def test_dirty_checkout_is_preserved(repos):
|
||||
installed, _ = repos
|
||||
before = git(installed, "rev-parse", "HEAD")
|
||||
(installed / "source.txt").write_text("user edits")
|
||||
assert update.update_source(installed, port=1) == 0
|
||||
assert update.update_source(installed) == 1
|
||||
assert git(installed, "rev-parse", "HEAD") == before
|
||||
assert (installed / "source.txt").read_text() == "user edits"
|
||||
|
||||
@@ -77,16 +77,32 @@ def test_diverged_checkout_is_not_merged(repos):
|
||||
git(installed, "add", "local.txt")
|
||||
git(installed, "commit", "-m", "local")
|
||||
before = git(installed, "rev-parse", "HEAD")
|
||||
assert update.update_source(installed, port=1) == 0
|
||||
assert update.update_source(installed) == 1
|
||||
assert git(installed, "rev-parse", "HEAD") == before
|
||||
|
||||
|
||||
def test_zip_install_continues_without_git_mutation(tmp_path, capsys):
|
||||
assert update.update_source(tmp_path, port=1) == 0
|
||||
def test_zip_install_fails_without_git_mutation(tmp_path, capsys):
|
||||
assert update.update_source(tmp_path) == 1
|
||||
assert not (tmp_path / ".git").exists()
|
||||
assert "Git" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_startup_check_reports_update_without_changing_source(repos):
|
||||
installed, _ = repos
|
||||
before = git(installed, "rev-parse", "HEAD")
|
||||
assert update.update_source(installed, check_only=True) == 10
|
||||
assert git(installed, "rev-parse", "HEAD") == before
|
||||
assert (installed / "source.txt").read_text() == "version1"
|
||||
|
||||
|
||||
def test_update_fetch_failure_is_not_reported_as_success(repos):
|
||||
installed, _ = repos
|
||||
before = git(installed, "rev-parse", "HEAD")
|
||||
git(installed, "remote", "set-url", "origin", str(installed / "missing-upstream"))
|
||||
assert update.update_source(installed) == 1
|
||||
assert git(installed, "rev-parse", "HEAD") == before
|
||||
|
||||
|
||||
def test_codex_refresh_preserves_credentials_restrictions_and_other_settings(tmp_path):
|
||||
root = tmp_path / "project"
|
||||
path = tmp_path / "config.toml"
|
||||
|
||||
@@ -112,7 +112,7 @@ def test_unpacked_archive_fast_forwards(repository, tmp_path):
|
||||
git(root, "push", "test-upstream", "main")
|
||||
from scripts.update_installation import update_source
|
||||
|
||||
assert update_source(extracted, port=1) == 10
|
||||
assert update_source(extracted) == 10
|
||||
assert (extracted / "start.bat").read_text() == "updated"
|
||||
assert git(extracted, "status", "--porcelain") == ""
|
||||
|
||||
|
||||
@@ -153,12 +153,22 @@ def test_chinese_batch_prints_complete_lines_and_preserves_exit_code(tmp_path, e
|
||||
root = tmp_path / "source with spaces!"
|
||||
root.mkdir()
|
||||
shutil.copy2(ROOT / "start-cn.bat", root / "start-cn.bat")
|
||||
(root / "start.bat").write_bytes(
|
||||
f'@echo off\r\nif not "%~1"=="-Mirror" exit /b 19\r\n'
|
||||
f'if not "%~2"=="China" exit /b 20\r\nexit /b {exit_code}\r\n'.encode("ascii")
|
||||
shutil.copy2(ROOT / "start.bat", root / "start.bat")
|
||||
(root / "scripts").mkdir()
|
||||
# Exercise the real Chinese banner/help prologue, stopping before installs,
|
||||
# update checks or downloads. Chinese now belongs to PowerShell, not CMD.
|
||||
prologue = (
|
||||
(ROOT / "scripts/start.ps1")
|
||||
.read_text(encoding="utf-8-sig")
|
||||
.split("# Ensure modern TLS", 1)[0]
|
||||
)
|
||||
(root / "scripts/start.ps1").write_text(
|
||||
prologue + f"\nif ($Mirror -ne 'China') {{ exit 20 }}\nexit {exit_code}\n",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", "call", str(root / "start-cn.bat")],
|
||||
env={**os.environ, "ARTEMIS_NO_PAUSE": "1"},
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Verify frozen upgrades and process ownership in disposable Windows installations."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform != "win32" or not shutil.which("powershell.exe") or not shutil.which("uv"),
|
||||
reason="Windows PowerShell and uv required",
|
||||
)
|
||||
|
||||
|
||||
def powershell(script, *, env=None, cwd=None):
|
||||
return subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script)],
|
||||
cwd=cwd,
|
||||
env={**os.environ, **(env or {})},
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=45,
|
||||
)
|
||||
|
||||
|
||||
def git(root, *args):
|
||||
return (
|
||||
subprocess.run(["git", "-C", str(root), *args], capture_output=True, check=True, timeout=15)
|
||||
.stdout.decode()
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def make_venv(root):
|
||||
subprocess.run(
|
||||
["uv", "venv", "--offline", "--python", sys.executable, str(root / ".venv")],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
return root / ".venv/Scripts/python.exe"
|
||||
|
||||
|
||||
def test_process_selection_is_scoped_and_includes_descendants(tmp_path):
|
||||
root = tmp_path / "project"
|
||||
|
||||
def process(pid, executable, command, name="python.exe", parent=0):
|
||||
return dict(
|
||||
ProcessId=pid,
|
||||
ParentProcessId=parent,
|
||||
ExecutablePath=executable,
|
||||
CommandLine=command,
|
||||
Name=name,
|
||||
)
|
||||
|
||||
processes = [
|
||||
process(10, str(root / ".venv/Scripts/python.exe"), "python -m artemis ui"),
|
||||
process(11, "C:\\Python\\python.exe", "python -m multiprocessing.spawn", parent=10),
|
||||
process(12, "C:\\tools\\ffmpeg.exe", "ffmpeg recording", "ffmpeg.exe", parent=11),
|
||||
process(13, "C:\\tools\\adb.exe", "adb server", "adb.exe", parent=10),
|
||||
process(14, "C:\\apps\\codex.exe", "codex", "codex.exe", parent=10),
|
||||
process(
|
||||
20,
|
||||
str(root.with_name("project-other") / ".venv/Scripts/python.exe"),
|
||||
"python -m artemis ui",
|
||||
),
|
||||
process(21, "C:\\Python\\python.exe", "python -m artemis ui"),
|
||||
process(22, "C:\\Python\\python.exe", f'python "{root}/mcp_server/__main__.py"'),
|
||||
process(23, "C:\\Python\\python.exe", f'python "{root}/unrelated.py"'),
|
||||
process(24, str(root / ".venv/Scripts/python.exe"), "protected updater"),
|
||||
process(25, "C:\\tools\\emulator.exe", "emulator", "emulator.exe", parent=10),
|
||||
]
|
||||
fixture = tmp_path / "processes.json"
|
||||
fixture.write_text(json.dumps(processes), encoding="utf-8")
|
||||
script = tmp_path / "select.ps1"
|
||||
script.write_text(
|
||||
"[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)\n"
|
||||
f". '{ROOT / 'scripts/upgrade_processes.ps1'}'\n"
|
||||
"$snapshot = Get-Content -Raw -LiteralPath $env:PROCESS_FIXTURE | ConvertFrom-Json\n"
|
||||
"$ids = @(Get-ArtemisUpgradeProcesses -ProjectRoot $env:PROJECT_ROOT -Processes $snapshot -ProtectedIds @(24) | ForEach-Object { [int]$_.ProcessId } | Sort-Object)\n"
|
||||
"ConvertTo-Json -InputObject $ids -Compress\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
result = powershell(script, env={"PROCESS_FIXTURE": str(fixture), "PROJECT_ROOT": str(root)})
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert json.loads(result.stdout) == [10, 11, 12, 22]
|
||||
|
||||
|
||||
def test_real_project_process_tree_stops_but_unrelated_process_survives(tmp_path):
|
||||
import psutil
|
||||
|
||||
root = tmp_path / "project with spaces!"
|
||||
root.mkdir()
|
||||
python = make_venv(root)
|
||||
pid_file = root / "child.pid"
|
||||
sleeper = root / "sleeper.py"
|
||||
sleeper.write_text(
|
||||
"import pathlib, subprocess, sys, time\n"
|
||||
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
|
||||
"pathlib.Path(sys.argv[1]).write_text(str(child.pid))\n"
|
||||
"time.sleep(60)\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
project = subprocess.Popen([str(python), str(sleeper), str(pid_file)])
|
||||
unrelated = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
|
||||
child_pid = None
|
||||
try:
|
||||
for _ in range(100):
|
||||
if pid_file.exists():
|
||||
child_pid = int(pid_file.read_text())
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert child_pid is not None
|
||||
script = tmp_path / "stop.ps1"
|
||||
script.write_text(
|
||||
"$ErrorActionPreference = 'Stop'\n"
|
||||
f". '{ROOT / 'scripts/upgrade_processes.ps1'}'\n"
|
||||
"Stop-ArtemisUpgradeProcesses -ProjectRoot $env:PROJECT_ROOT\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
result = powershell(script, env={"PROJECT_ROOT": str(root)})
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
project.wait(timeout=5)
|
||||
assert unrelated.poll() is None
|
||||
assert not psutil.pid_exists(child_pid)
|
||||
finally:
|
||||
for process in (project, unrelated):
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if child_pid and psutil.pid_exists(child_pid):
|
||||
psutil.Process(child_pid).kill()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prepare_exit", [0, 7])
|
||||
def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
|
||||
tmp_path, prepare_exit
|
||||
):
|
||||
if not shutil.which("git"):
|
||||
pytest.skip("Git required")
|
||||
author = tmp_path / "author"
|
||||
(author / "scripts").mkdir(parents=True)
|
||||
for name in (
|
||||
"upgrade.ps1",
|
||||
"upgrade_processes.ps1",
|
||||
"installation_lock.ps1",
|
||||
"update_installation.py",
|
||||
):
|
||||
shutil.copyfile(ROOT / "scripts" / name, author / "scripts" / name)
|
||||
for name in ("update.bat", "update-cn.bat"):
|
||||
shutil.copyfile(ROOT / name, author / name)
|
||||
(author / ".gitignore").write_text(".env\n.venv/\nprepared.txt\n")
|
||||
(author / "scripts/start.ps1").write_text("exit 23\n", encoding="ascii")
|
||||
git(author, "init", "-b", "main")
|
||||
git(author, "config", "user.name", "Fixture")
|
||||
git(author, "config", "user.email", "fixture@example.test")
|
||||
git(author, "add", ".")
|
||||
git(author, "commit", "-m", "old version")
|
||||
installed = tmp_path / "installed with spaces!"
|
||||
git(tmp_path, "clone", "-b", "main", str(author), str(installed))
|
||||
make_venv(installed)
|
||||
(installed / ".env").write_text("secret stays local")
|
||||
# A running file AND both BAT wrappers are replaced during the merge.
|
||||
(author / "scripts/upgrade.ps1").write_text("throw 'new updater must not run in this turn'\n")
|
||||
(author / "scripts/upgrade_processes.ps1").write_text(
|
||||
"throw 'new process helper must not run'\n"
|
||||
)
|
||||
(author / "update.bat").write_text("@echo off\nexit /b 33\n")
|
||||
(author / "update-cn.bat").write_text("@echo off\nexit /b 34\n")
|
||||
(author / "scripts/start.ps1").write_text(
|
||||
"param([switch]$PrepareOnly, [switch]$SkipUpdate, [switch]$NoOpen, [string]$Mirror)\n"
|
||||
"if (-not ($PrepareOnly -and $SkipUpdate -and $NoOpen) -or $Mirror -ne 'China' -or $env:ARTEMIS_UPGRADE_CHILD -ne '1') { exit 35 }\n"
|
||||
"Set-Content -LiteralPath (Join-Path (Split-Path $PSScriptRoot -Parent) 'prepared.txt') -Value 'new version prepared'\n"
|
||||
f"exit {prepare_exit}\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
git(author, "add", ".")
|
||||
git(author, "commit", "-m", "new version")
|
||||
result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", "update-cn.bat"],
|
||||
cwd=installed,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=45,
|
||||
)
|
||||
assert result.returncode == (0 if prepare_exit == 0 else 1), result.stdout + result.stderr
|
||||
assert result.stderr == ""
|
||||
assert (installed / "prepared.txt").read_text().strip() == "new version prepared"
|
||||
assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
|
||||
assert (installed / ".env").read_text() == "secret stays local"
|
||||
assert ("升级完成" in result.stdout) == (prepare_exit == 0)
|
||||
assert "new updater must not run" not in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["update.bat", "update-cn.bat"])
|
||||
@pytest.mark.parametrize("help_flag", ["-Help", "--help"])
|
||||
def test_upgrade_help_is_safe_and_wrappers_are_ascii(name, help_flag):
|
||||
(ROOT / name).read_bytes().decode("ascii")
|
||||
result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", name, help_flag],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert result.stderr == ""
|
||||
assert "升级会终止本项目" in result.stdout
|
||||
|
||||
|
||||
def test_installation_lock_blocks_same_checkout_and_recovers_after_owner_crash(tmp_path):
|
||||
holder_script = tmp_path / "holder.ps1"
|
||||
holder_script.write_text(
|
||||
f". '{ROOT / 'scripts/installation_lock.ps1'}'\n"
|
||||
"$lock = Enter-ArtemisInstallationLock -ProjectRoot $env:PROJECT_ROOT\n"
|
||||
"Set-Content -LiteralPath $env:READY_FILE -Value 'ready'\n"
|
||||
"Start-Sleep -Seconds 60\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
ready = tmp_path / "ready.txt"
|
||||
root = tmp_path / "project"
|
||||
holder = subprocess.Popen(
|
||||
["powershell.exe", "-NoProfile", "-File", str(holder_script)],
|
||||
env={**os.environ, "PROJECT_ROOT": str(root), "READY_FILE": str(ready)},
|
||||
)
|
||||
contender = tmp_path / "contender.ps1"
|
||||
contender.write_text(
|
||||
f". '{ROOT / 'scripts/installation_lock.ps1'}'\n"
|
||||
"try {\n"
|
||||
" $lock = Enter-ArtemisInstallationLock -ProjectRoot $env:PROJECT_ROOT\n"
|
||||
" Exit-ArtemisInstallationLock -Mutex $lock\n"
|
||||
" exit 0\n"
|
||||
"} catch { Write-Host 'LOCK_BUSY'; exit 37 }\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
try:
|
||||
for _ in range(100):
|
||||
if ready.exists():
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert ready.exists()
|
||||
blocked = powershell(contender, env={"PROJECT_ROOT": str(root)})
|
||||
assert blocked.returncode == 37, blocked.stdout + blocked.stderr
|
||||
assert (
|
||||
powershell(contender, env={"PROJECT_ROOT": str(root.with_name("other"))}).returncode
|
||||
== 0
|
||||
)
|
||||
holder.kill()
|
||||
holder.wait(timeout=5)
|
||||
assert powershell(contender, env={"PROJECT_ROOT": str(root)}).returncode == 0
|
||||
finally:
|
||||
if holder.poll() is None:
|
||||
holder.kill()
|
||||
holder.wait(timeout=5)
|
||||
|
||||
|
||||
def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_path):
|
||||
root = tmp_path / "prepared project"
|
||||
(root / "scripts").mkdir(parents=True)
|
||||
for name in ("start.ps1", "installation_lock.ps1"):
|
||||
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
|
||||
(root / "scripts/download_config.ps1").write_text(
|
||||
"function Initialize-ArtemisDownloads { $script:ArtemisDownloads = @{mode='Official'} }\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
(root / "scripts/bootstrap_python.ps1").write_text(
|
||||
"function Initialize-ArtemisPython { return $env:TEST_PYTHON }\n", encoding="ascii"
|
||||
)
|
||||
(root / "scripts/update_installation.py").write_text(
|
||||
"import pathlib, sys\n"
|
||||
"root = pathlib.Path(__file__).parent.parent\n"
|
||||
"with (root / 'checks.txt').open('a') as f: f.write(sys.argv[1] + '\\n')\n"
|
||||
"sys.exit(0)\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
(root / ".env").write_text("keep credentials")
|
||||
index = root / "apps/showcase_ui/dist/frontend/browser/index.html"
|
||||
index.parent.mkdir(parents=True)
|
||||
index.write_text("built frontend")
|
||||
tools = root / "stub-tools"
|
||||
tools.mkdir()
|
||||
for name in ("adb", "scrcpy", "ffmpeg"):
|
||||
(tools / f"{name}.cmd").write_text("@echo off\nexit /b 91\n")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(root / "scripts/start.ps1"),
|
||||
"-PrepareOnly",
|
||||
"-SkipUpdate",
|
||||
"-NoOpen",
|
||||
],
|
||||
env={
|
||||
**os.environ,
|
||||
"TEST_PYTHON": sys.executable,
|
||||
"PATH": str(tools) + os.pathsep + os.environ["PATH"],
|
||||
},
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert (root / "checks.txt").read_text().splitlines() == ["mcp", "frontend-check"]
|
||||
assert "Launching Artemis" not in result.stdout
|
||||
assert "ADB" not in result.stderr
|
||||
assert (root / ".env").read_text() == "keep credentials"
|
||||
|
||||
|
||||
def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
|
||||
if not shutil.which("git"):
|
||||
pytest.skip("Git required")
|
||||
root = tmp_path / "dirty installation"
|
||||
(root / "scripts").mkdir(parents=True)
|
||||
for name in (
|
||||
"upgrade.ps1",
|
||||
"upgrade_processes.ps1",
|
||||
"installation_lock.ps1",
|
||||
"update_installation.py",
|
||||
):
|
||||
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
|
||||
(root / ".gitignore").write_text(".venv/\n")
|
||||
source = root / "user.txt"
|
||||
source.write_text("original")
|
||||
git(root, "init", "-b", "main")
|
||||
git(root, "config", "user.name", "Fixture")
|
||||
git(root, "config", "user.email", "fixture@example.test")
|
||||
git(root, "add", ".")
|
||||
git(root, "commit", "-m", "initial")
|
||||
python = make_venv(root)
|
||||
source.write_text("user changes")
|
||||
worker = subprocess.Popen([str(python), "-c", "import time; time.sleep(60)"])
|
||||
try:
|
||||
result = powershell(root / "scripts/upgrade.ps1")
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
assert "发现本地修改" in result.stdout
|
||||
assert "[STOP]" not in result.stdout
|
||||
assert worker.poll() is None
|
||||
assert source.read_text() == "user changes"
|
||||
finally:
|
||||
# Windows venv redirectors can have a second interpreter process.
|
||||
import psutil
|
||||
|
||||
if worker.poll() is None:
|
||||
children = psutil.Process(worker.pid).children(recursive=True)
|
||||
worker.kill()
|
||||
for child in children:
|
||||
if child.is_running():
|
||||
child.kill()
|
||||
worker.wait(timeout=5)
|
||||
Reference in new issue
Block a user