"""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) == 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) == 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) == 1 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) == 1 assert git(installed, "rev-parse", "HEAD") == before def test_zip_install_fails_without_git_mutation(tmp_path, capsys): assert update.update_source(tmp_path) == 1 assert not (tmp_path / ".git").exists() assert "Git" in capsys.readouterr().out def test_startup_check_reports_update_without_changing_source(repos): installed, _ = repos before = git(installed, "rev-parse", "HEAD") assert update.update_source(installed, check_only=True) == 10 assert git(installed, "rev-parse", "HEAD") == before assert (installed / "source.txt").read_text() == "version1" def test_update_fetch_failure_is_not_reported_as_success(repos): installed, _ = repos before = git(installed, "rev-parse", "HEAD") git(installed, "remote", "set-url", "origin", str(installed / "missing-upstream")) assert update.update_source(installed) == 1 assert git(installed, "rev-parse", "HEAD") == before 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 required = true 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["required"] 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"] 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 @pytest.mark.parametrize( "policies", [ '# Keep my tool policies\ntools = { mobile_run_task = { enabled = false, approval_policy = "on-request" }, retired_tool = { enabled = false } }\n', '# Keep my tool policies\ntools.mobile_run_task.enabled = false\ntools.mobile_run_task.approval_policy = "on-request"\ntools.retired_tool.enabled = false\n', '# Keep my tool policies\n[mcp_servers.artemis.tools.mobile_run_task]\nenabled = false\napproval_policy = "on-request"\n[mcp_servers.artemis.tools.retired_tool]\nenabled = false\n', ], ids=["inline-table", "dotted-keys", "tool-subtables"], ) def test_codex_sync_preserves_tool_policies_verbatim(tmp_path, monkeypatch, policies): from artemis.interfaces.cli.commands import mcp as mcp_command codex_home = tmp_path / "中文用户" / ".codex" codex_home.mkdir(parents=True) path = codex_home / "config.toml" original = ( '[mcp_servers.artemis]\ncommand = "old-python"\n' 'args = ["-m", "mcp_server"]\n' 'enabled_tools = ["mobile_run_task"]\n' 'disabled_tools = ["mobile_config"]\n' 'env = { PYTHONPATH = "C:/old", OPENAI_API_KEY = "test-secret" }\n' + policies + '[mcp_servers.other]\ncommand = "leave-alone"\n' ) path.write_text(original, encoding="utf-8") before = tomllib.loads(original) monkeypatch.setenv("CODEX_HOME", str(codex_home)) root = tmp_path / "新版项目" assert update.refresh_mcp(root) == 0 refreshed = path.read_text(encoding="utf-8") after = tomllib.loads(refreshed) entry = after["mcp_servers"]["artemis"] assert entry["command"] == update.sys.executable assert entry["cwd"] == str(root) assert entry["env"] == { "PYTHONPATH": str(root), "OPENAI_API_KEY": "test-secret", "PYTHONUNBUFFERED": "1", "ARTEMIS_DESKTOP_NOTIFY": "true", } assert entry["enabled_tools"] == ["mobile_run_task"] assert entry["disabled_tools"] == ["mobile_config"] assert entry["tools"] == before["mcp_servers"]["artemis"]["tools"] assert policies.split("\n", 1)[1] in refreshed assert refreshed.count("# Keep my tool policies") == 1 assert after["mcp_servers"]["other"] == before["mcp_servers"]["other"] assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original assert update.refresh_mcp(root) == 0 assert path.read_text(encoding="utf-8") == refreshed def test_codex_sync_reports_generated_toml_error_without_blame_or_overwrite( tmp_path, monkeypatch, capsys ): codex_home = tmp_path / ".codex" codex_home.mkdir() path = codex_home / "config.toml" original = '[mcp_servers.artemis]\ncommand = "old"\nargs = ["-m", "mcp_server"]\n' path.write_text(original, encoding="utf-8") monkeypatch.setenv("CODEX_HOME", str(codex_home)) monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN") monkeypatch.setattr( "artemis.integrations.codex_mcp._replace_toml_tables", lambda *_: 'tools = {"secret": true}\n', ) assert update.refresh_mcp(tmp_path / "project") == 1 assert path.read_text(encoding="utf-8") == original assert not path.with_name(path.name + ".artemis-update.bak").exists() output = capsys.readouterr().out assert "原配置可正常解析" in output assert "ARTEMIS 同步错误" in output assert "配置结构无效" not in output assert "secret" not in output def test_mcp_sync_updates_only_codex_and_preserves_other_clients(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"}}}') vscode_home = home / "AppData/Code/User" vscode_home.mkdir(parents=True) vscode_config = vscode_home / "mcp.json" vscode_config.write_bytes(b"{invalid VS Code JSONC}") monkeypatch.setattr("pathlib.Path.home", lambda: home) 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"] == ["mobile_run_task", "retired_tool"] cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8")) assert "artemis" not in cursor["mcpServers"] assert cursor["mcpServers"]["other"] == {"command": "keep"} assert vscode_config.read_bytes() == b"{invalid VS Code JSONC}" assert not vscode_config.with_name("mcp.json.artemis-update.bak").exists() 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_codex_config_without_overwriting(tmp_path, monkeypatch, capsys): home = tmp_path / "home" codex_home = home / ".codex" codex_home.mkdir(parents=True) config = codex_home / "config.toml" original = b'model = "keep-secret"\n[mcp_servers.artemis\n' config.write_bytes(original) monkeypatch.setattr("pathlib.Path.home", lambda: home) monkeypatch.setenv("CODEX_HOME", str(codex_home)) assert update.refresh_mcp(tmp_path / "project") == 1 assert config.read_bytes() == original assert not config.with_name(config.name + ".artemis-update.bak").exists() output = capsys.readouterr().out assert str(config) in output assert "Invalid TOML" in output assert "keep-secret" not in output 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.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) @pytest.mark.parametrize("codex_home_setting", [None, "", "custom"]) def test_codex_sync_reports_actual_path_and_ready_state( tmp_path, monkeypatch, capsys, codex_home_setting ): home = tmp_path / "home" codex_home = home / ".codex" if codex_home_setting != "custom" else tmp_path / "custom Codex" codex_home.mkdir(parents=True) monkeypatch.setattr("pathlib.Path.home", lambda: home) monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN") if codex_home_setting is None: monkeypatch.delenv("CODEX_HOME", raising=False) else: monkeypatch.setenv("CODEX_HOME", str(codex_home) if codex_home_setting == "custom" else "") root = tmp_path / "project" assert update.refresh_mcp(root) == 0 first = capsys.readouterr().out assert str(codex_home / "config.toml") in first assert "已安装" in first assert update.refresh_mcp(root) == 0 second = capsys.readouterr().out assert str(codex_home / "config.toml") in second assert "已是最新" in second if codex_home_setting == "custom": assert not (home / ".codex").exists() def test_codex_sync_reports_missing_directory_without_touching_other_clients( tmp_path, monkeypatch, capsys ): monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) monkeypatch.delenv("CODEX_HOME", raising=False) monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN") assert update.refresh_mcp(tmp_path / "project") == 0 output = capsys.readouterr().out assert str(tmp_path / ".codex/config.toml") in output assert "已安装" in output assert (tmp_path / ".codex/config.toml").is_file() assert (tmp_path / ".agents/skills/artemis-use/SKILL.md").is_file() def test_custom_codex_command_is_preserved_and_reported(tmp_path, monkeypatch, capsys): codex_home = tmp_path / ".codex" codex_home.mkdir() config = codex_home / "config.toml" original = '[mcp_servers.artemis]\ncommand = "custom"\nargs = ["wrapper"]\n' config.write_text(original, encoding="utf-8") monkeypatch.setenv("CODEX_HOME", str(codex_home)) monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN") assert update.refresh_mcp(tmp_path / "project") == 0 assert config.read_text(encoding="utf-8") == original assert "自定义" in capsys.readouterr().out 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_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