Files
Artemis-Eapil/tests/unit/test_installation_update.py
T

413 lines
17 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) == 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
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
@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"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
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(update, "_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"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
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 "未发现 Codex 配置目录" in output
assert "--install codex" in output
assert not (tmp_path / ".codex").exists()
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