358 lines
14 KiB
Python
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)
|