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