Files
Artemis-Eapil/tests/unit/test_windows_upgrade.py
T

358 lines
14 KiB
Python

"""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)