Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
418 lines
17 KiB
Python
418 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
|
|
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
|