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
@@ -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