310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""Exercise upgrades against disposable Git repos and client configs, never the user's repo."""
|
|
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import tomllib
|
|
|
|
import pytest
|
|
|
|
MODULE_PATH = Path(__file__).resolve().parents[2] / "scripts/update_installation.py"
|
|
spec = importlib.util.spec_from_file_location("installation_update", MODULE_PATH)
|
|
update = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(update)
|
|
|
|
|
|
def git(path, *args):
|
|
result = subprocess.run(
|
|
["git", "-C", str(path), *args], capture_output=True, text=True, timeout=15
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
return result.stdout.strip()
|
|
|
|
|
|
@pytest.fixture
|
|
def repos(tmp_path):
|
|
if not shutil.which("git"):
|
|
pytest.skip("Git required")
|
|
remote = tmp_path / "upstream.git"
|
|
remote.mkdir()
|
|
git(remote, "init", "--bare")
|
|
author = tmp_path / "author"
|
|
author.mkdir()
|
|
git(author, "init", "-b", "main")
|
|
git(author, "config", "user.name", "Test Fixture")
|
|
git(author, "config", "user.email", "fixture@example.test")
|
|
(author / "source.txt").write_text("version1")
|
|
(author / ".gitignore").write_text(".env\n.venv/\n")
|
|
git(author, "add", "source.txt", ".gitignore")
|
|
git(author, "commit", "-m", "initial")
|
|
git(author, "remote", "add", "origin", str(remote))
|
|
git(author, "push", "-u", "origin", "main")
|
|
installed = tmp_path / "installed with spaces"
|
|
git(tmp_path, "clone", "-b", "main", str(remote), str(installed))
|
|
(installed / ".env").write_text("secret stays local")
|
|
(author / "source.txt").write_text("version2")
|
|
git(author, "add", "source.txt")
|
|
git(author, "commit", "-m", "upgrade")
|
|
git(author, "push")
|
|
return installed, author
|
|
|
|
|
|
def test_fast_forward_update_preserves_env_and_is_repeatable(repos):
|
|
installed, author = repos
|
|
assert update.update_source(installed, port=1) == 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
|
|
|
|
|
|
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 git(installed, "rev-parse", "HEAD") == before
|
|
assert (installed / "source.txt").read_text() == "user edits"
|
|
|
|
|
|
def test_diverged_checkout_is_not_merged(repos):
|
|
installed, _ = repos
|
|
git(installed, "config", "user.name", "Test Fixture")
|
|
git(installed, "config", "user.email", "fixture@example.test")
|
|
(installed / "local.txt").write_text("local commit")
|
|
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 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
|
|
assert not (tmp_path / ".git").exists()
|
|
assert "Git" in capsys.readouterr().out
|
|
|
|
|
|
def test_codex_refresh_preserves_credentials_restrictions_and_other_settings(tmp_path):
|
|
root = tmp_path / "project"
|
|
path = tmp_path / "config.toml"
|
|
original = f"""model = "custom"
|
|
[mcp_servers.artemis]
|
|
command = "old-python"
|
|
cwd = {json.dumps(str(root))}
|
|
args = ["-m", "mcp_server"]
|
|
enabled = false
|
|
enabled_tools = [
|
|
"mobile_run_task",
|
|
]
|
|
disabled_tools = ["mobile_config"]
|
|
tool_timeout_sec = 900
|
|
[mcp_servers.artemis.env]
|
|
OPENAI_API_KEY = "test-only-secret"
|
|
[mcp_servers.other]
|
|
command = "leave-alone"
|
|
"""
|
|
path.write_text(original, encoding="utf-8")
|
|
assert update.refresh_codex(path, root, "new-python", ("mobile_run_task", "mobile_batch"))
|
|
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
config = data["mcp_servers"]["artemis"]
|
|
assert config["enabled"] is False
|
|
assert config["disabled_tools"] == ["mobile_config"]
|
|
assert config["tool_timeout_sec"] == 900
|
|
assert config["env"]["OPENAI_API_KEY"] == "test-only-secret"
|
|
assert config["enabled_tools"] == ["mobile_run_task", "mobile_batch"]
|
|
assert data["mcp_servers"]["other"]["command"] == "leave-alone"
|
|
assert not update.refresh_codex(path, root, "new-python", ("mobile_run_task", "mobile_batch"))
|
|
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
|
|
|
|
|
|
def test_json_migrates_existing_standard_mcp_to_current_checkout(tmp_path):
|
|
path = tmp_path / "mcp.json"
|
|
root = tmp_path / "project"
|
|
config = {
|
|
"cwd": str(root),
|
|
"command": "old",
|
|
"args": ["-m", "mcp_server"],
|
|
"disabled": True,
|
|
"env": {"CUSTOM": "preserved"},
|
|
"autoApprove": [],
|
|
}
|
|
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
|
|
moved_root = tmp_path / "new release"
|
|
assert update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
|
|
result = json.loads(path.read_text())["mcpServers"]
|
|
assert result["artemis"]["env"]["CUSTOM"] == "preserved"
|
|
assert result["artemis"]["env"]["PYTHONPATH"] == str(moved_root)
|
|
assert result["artemis"]["cwd"] == str(moved_root)
|
|
assert result["artemis"]["disabled"] is True
|
|
assert result["other"] == {"command": "keep"}
|
|
assert not update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
|
|
missing = tmp_path / "not-installed.json"
|
|
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
|
|
assert not missing.exists()
|
|
|
|
|
|
def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeypatch):
|
|
from artemis.interfaces.cli.commands import mcp as mcp_command
|
|
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
codex_home = home / ".codex"
|
|
codex_home.mkdir()
|
|
codex_config = codex_home / "config.toml"
|
|
codex_config.write_text(
|
|
'model = "my-model"\n'
|
|
"[mcp_servers.artemis]\n"
|
|
'command = "old-python"\n'
|
|
'args = ["-m", "mcp_server.server"]\n'
|
|
'cwd = "C:/old-release"\n'
|
|
'enabled_tools = ["mobile_run_task", "retired_tool"]\n'
|
|
"enabled = false\n"
|
|
"[mcp_servers.artemis.env]\n"
|
|
'PYTHONPATH = "C:/old-release"\n'
|
|
'OPENAI_API_KEY = "keep-secret"\n',
|
|
encoding="utf-8",
|
|
)
|
|
cursor_home = home / ".cursor"
|
|
cursor_home.mkdir()
|
|
(cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}')
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
|
|
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
|
monkeypatch.setenv("APPDATA", str(home / "AppData"))
|
|
root = tmp_path / "new release"
|
|
assert update.refresh_mcp(root) == 0
|
|
codex = tomllib.loads(codex_config.read_text(encoding="utf-8"))
|
|
entry = codex["mcp_servers"]["artemis"]
|
|
assert codex["model"] == "my-model"
|
|
assert entry["command"] == update.sys.executable
|
|
assert entry["cwd"] == str(root)
|
|
assert entry["args"] == ["-m", "mcp_server"]
|
|
assert entry["enabled"] is False
|
|
assert entry["env"]["OPENAI_API_KEY"] == "keep-secret"
|
|
assert entry["env"]["PYTHONPATH"] == str(root)
|
|
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
|
|
cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8"))
|
|
assert cursor["mcpServers"]["artemis"]["env"]["PYTHONPATH"] == str(root)
|
|
assert cursor["mcpServers"]["other"] == {"command": "keep"}
|
|
first = codex_config.read_text(encoding="utf-8")
|
|
assert update.refresh_mcp(root) == 0
|
|
assert codex_config.read_text(encoding="utf-8") == first
|
|
|
|
|
|
def test_mcp_sync_fails_on_invalid_detected_config_without_overwriting(tmp_path, monkeypatch):
|
|
from artemis.interfaces.cli.commands import mcp as mcp_command
|
|
|
|
home = tmp_path / "home"
|
|
cursor_home = home / ".cursor"
|
|
cursor_home.mkdir(parents=True)
|
|
config = cursor_home / "mcp.json"
|
|
config.write_text("{invalid json", encoding="utf-8")
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
|
|
monkeypatch.delenv("CODEX_HOME", raising=False)
|
|
assert update.refresh_mcp(tmp_path / "project") == 1
|
|
assert config.read_text(encoding="utf-8") == "{invalid json"
|
|
|
|
|
|
def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypatch):
|
|
from artemis.interfaces.cli.commands import mcp as mcp_command
|
|
|
|
home = tmp_path / "home"
|
|
codex_home = home / ".codex"
|
|
codex_home.mkdir(parents=True)
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
|
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
|
|
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
|
root = tmp_path / "project"
|
|
assert update.refresh_mcp(root) == 0
|
|
config = tomllib.loads((codex_home / "config.toml").read_text(encoding="utf-8"))
|
|
assert config["mcp_servers"]["artemis"]["cwd"] == str(root)
|
|
assert config["mcp_servers"]["artemis"]["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
|
|
|
|
|
|
def test_codex_install_preserves_existing_user_tables(tmp_path):
|
|
path = tmp_path / "config.toml"
|
|
path.write_text(
|
|
'model = "keep"\n[mcp_servers.other]\ncommand = "other-tool"\n', encoding="utf-8"
|
|
)
|
|
template = {
|
|
"command": "new-python",
|
|
"args": ["-m", "mcp_server"],
|
|
"cwd": str(tmp_path),
|
|
"env": {"PYTHONPATH": str(tmp_path)},
|
|
"enabled_tools": ["mobile_run_task"],
|
|
}
|
|
assert update.refresh_codex(
|
|
path,
|
|
tmp_path,
|
|
"new-python",
|
|
("mobile_run_task",),
|
|
template=template,
|
|
install_missing=True,
|
|
)
|
|
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
assert data["model"] == "keep"
|
|
assert data["mcp_servers"]["other"]["command"] == "other-tool"
|
|
assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
|
|
|
|
|
|
def test_codex_inline_environment_sync_is_idempotent(tmp_path):
|
|
path = tmp_path / "config.toml"
|
|
path.write_text(
|
|
'[mcp_servers.artemis]\ncommand = "old-python"\n'
|
|
'args = ["-m", "mcp_server"]\n'
|
|
'env = { PYTHONPATH = "C:/old", OPENAI_API_KEY = "keep-secret" }\n',
|
|
encoding="utf-8",
|
|
)
|
|
template = {
|
|
"command": "new-python",
|
|
"args": ["-m", "mcp_server"],
|
|
"cwd": str(tmp_path),
|
|
"env": {"PYTHONPATH": str(tmp_path)},
|
|
"enabled_tools": ["mobile_run_task"],
|
|
}
|
|
assert update.refresh_codex(
|
|
path, tmp_path, "new-python", ("mobile_run_task",), template=template
|
|
)
|
|
data = tomllib.loads(path.read_text(encoding="utf-8"))["mcp_servers"]["artemis"]
|
|
assert data["env"]["OPENAI_API_KEY"] == "keep-secret"
|
|
assert data["env"]["PYTHONPATH"] == str(tmp_path)
|
|
assert not update.refresh_codex(
|
|
path, tmp_path, "new-python", ("mobile_run_task",), template=template
|
|
)
|
|
|
|
|
|
def test_custom_mcp_wrapper_is_left_unchanged(tmp_path):
|
|
path = tmp_path / "mcp.json"
|
|
original = '{"mcpServers":{"artemis":{"command":"uv","args":["run","custom"]}}}'
|
|
path.write_text(original, encoding="utf-8")
|
|
assert (
|
|
update.refresh_json(
|
|
path,
|
|
("mcpServers",),
|
|
tmp_path,
|
|
"new-python",
|
|
("mobile_run_task",),
|
|
install_missing=True,
|
|
template={"command": "new-python", "args": ["-m", "mcp_server"], "env": {}},
|
|
)
|
|
is None
|
|
)
|
|
assert path.read_text(encoding="utf-8") == original
|
|
|
|
|
|
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
|
|
ui = tmp_path / "apps/showcase_ui"
|
|
(ui / "src").mkdir(parents=True)
|
|
(ui / "src/app.ts").write_text("one")
|
|
(ui / "package-lock.json").write_text("lock1")
|
|
first = update.frontend_fingerprint(tmp_path)
|
|
(ui / "package-lock.json").write_text("lock2")
|
|
second = update.frontend_fingerprint(tmp_path)
|
|
assert first != second
|
|
(ui / "src/app.ts").unlink()
|
|
assert update.frontend_fingerprint(tmp_path) != second
|