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

+14
View File
@@ -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(
+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"]
+106
View File
@@ -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")
+22 -6
View File
@@ -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"
+1 -1
View File
@@ -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") == ""
+13 -3
View File
@@ -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,
)
+357
View File
@@ -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)