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

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