refactor: drop workspace sync launcher path; simplify upgrade/switch
Align with xiaohe wheel/pip install: remove sync_workspace and desktop launcher coupling from upgrade/switch flows.
This commit is contained in:
+4
-125
@@ -1,134 +1,13 @@
|
||||
"""Tests for myagents.commands.switch (version / agent subcommands)."""
|
||||
"""Tests for myagents.commands.switch (legacy shim)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from myagents.commands import switch as sw_mod
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(sw_mod, "get_workspace_root", lambda create=False: workspace)
|
||||
# sync_workspace is imported locally in switch_version
|
||||
import myagents.commands.sync_workspace as sync_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
sync_mod,
|
||||
"sync_workspace",
|
||||
lambda _ws: {"added": [], "updated": [], "skipped": [], "removed": []},
|
||||
)
|
||||
# _install_tools is imported locally inside switch_version from upgrade
|
||||
import myagents.commands.upgrade as ug_mod
|
||||
|
||||
monkeypatch.setattr(ug_mod, "_install_tools", lambda _rt: [])
|
||||
return home
|
||||
|
||||
|
||||
def _make_versions(home: Path, versions: tuple[str, ...], current: str) -> None:
|
||||
root = home / ".xiaohe" / "runtime"
|
||||
for name in versions:
|
||||
(root / name).mkdir(parents=True)
|
||||
(root / "current").symlink_to(root / current)
|
||||
|
||||
|
||||
class TestSwitchVersion:
|
||||
def test_switches_current_and_reinstalls(
|
||||
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_make_versions(fake_home, ("v1", "v2"), "v2")
|
||||
installed: list[str] = []
|
||||
import myagents.commands.upgrade as ug_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
ug_mod,
|
||||
"_install_tools",
|
||||
lambda _rt: (installed.append(_rt.name), [])[1] or [],
|
||||
)
|
||||
result = CliRunner().invoke(sw_mod.switch_version, ["v1", "--yes"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Switched:" in result.output
|
||||
current = fake_home / ".xiaohe" / "runtime" / "current"
|
||||
assert current.resolve().name == "v1"
|
||||
assert installed == ["v1"]
|
||||
|
||||
def test_unknown_version_errors(self, fake_home: Path) -> None:
|
||||
_make_versions(fake_home, ("v1", "v2"), "v2")
|
||||
result = CliRunner().invoke(sw_mod.switch_version, ["v9", "--yes"])
|
||||
assert result.exit_code != 0
|
||||
assert "not installed" in result.output
|
||||
|
||||
def test_already_current_is_noop(self, fake_home: Path) -> None:
|
||||
_make_versions(fake_home, ("v1", "v2"), "v2")
|
||||
result = CliRunner().invoke(sw_mod.switch_version, ["v2", "--yes"])
|
||||
assert result.exit_code == 0
|
||||
assert "Already on v2" in result.output
|
||||
|
||||
def test_interactive_list_and_prompt(self, fake_home: Path) -> None:
|
||||
_make_versions(fake_home, ("v1", "v2"), "v2")
|
||||
result = CliRunner().invoke(sw_mod.switch_version, [], input="v2\ny\n")
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "v2" in result.output and "(current)" in result.output
|
||||
|
||||
def test_cancelled_keeps_current(self, fake_home: Path) -> None:
|
||||
_make_versions(fake_home, ("v1", "v2"), "v2")
|
||||
result = CliRunner().invoke(sw_mod.switch_version, ["v1"], input="n\n")
|
||||
assert result.exit_code == 0
|
||||
assert "Cancelled" in result.output
|
||||
assert (fake_home / ".xiaohe" / "runtime" / "current").resolve().name == "v2"
|
||||
|
||||
def test_no_versions_errors(self, fake_home: Path) -> None:
|
||||
result = CliRunner().invoke(sw_mod.switch_version, ["v1", "--yes"])
|
||||
assert result.exit_code != 0
|
||||
assert "No installed runtime" in result.output
|
||||
|
||||
|
||||
class TestSwitchAgent:
|
||||
def test_bare_invocation_lists_agents(self) -> None:
|
||||
result = CliRunner().invoke(
|
||||
sw_mod.switch_agent, [], input="claude\n"
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Available agents" in result.output
|
||||
assert "claude" in result.output
|
||||
assert "kimi" in result.output
|
||||
|
||||
def test_switch_to_kimi(self) -> None:
|
||||
result = CliRunner().invoke(sw_mod.switch_agent, ["kimi"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Switched" in result.output
|
||||
|
||||
def test_already_current_noop(self) -> None:
|
||||
from myagents.settings import get_setting
|
||||
|
||||
current = get_setting("default_agent", "claude")
|
||||
result = CliRunner().invoke(sw_mod.switch_agent, [current])
|
||||
assert result.exit_code == 0
|
||||
assert "Already on" in result.output
|
||||
|
||||
def test_unknown_agent_errors(self) -> None:
|
||||
result = CliRunner().invoke(sw_mod.switch_agent, ["unknown-ai"])
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown agent" in result.output
|
||||
|
||||
|
||||
class TestSwitchGroup:
|
||||
def test_bare_invocation_shows_options(self) -> None:
|
||||
class TestSwitchHelp:
|
||||
def test_group_mentions_upgrade(self) -> None:
|
||||
result = CliRunner().invoke(sw_mod.switch_cmd, [])
|
||||
assert result.exit_code == 0
|
||||
assert "switch version" in result.output
|
||||
assert "switch agent" in result.output
|
||||
assert "switch provider" in result.output
|
||||
|
||||
def test_help_lists_subcommands(self) -> None:
|
||||
result = CliRunner().invoke(sw_mod.switch_cmd, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "version" in result.output
|
||||
assert "xiaohe upgrade" in result.output
|
||||
assert "agent" in result.output
|
||||
assert "provider" in result.output
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Tests for myagents.commands.sync_workspace."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from myagents.commands import sync_workspace as sync_mod
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
rt = tmp_path / "runtime"
|
||||
(rt / ".agents" / "skills" / "demo").mkdir(parents=True)
|
||||
(rt / ".agents" / "skills" / "demo" / "SKILL.md").write_text("demo v1")
|
||||
(rt / "agents.md").write_text("agent rules v1")
|
||||
(rt / "VERSION").write_text("v0.0.0-test\n")
|
||||
monkeypatch.setattr(sync_mod, "get_runtime_root", lambda: rt)
|
||||
return rt
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def workspace(tmp_path: Path) -> Path:
|
||||
ws = tmp_path / "workspace"
|
||||
ws.mkdir()
|
||||
return ws
|
||||
|
||||
|
||||
def _read_stamp(ws: Path) -> dict:
|
||||
return json.loads((ws / sync_mod.STAMP_NAME).read_text())
|
||||
|
||||
|
||||
class TestSyncWorkspace:
|
||||
def test_new_files_copied_and_symlinks_made(
|
||||
self, runtime: Path, workspace: Path
|
||||
) -> None:
|
||||
report = sync_mod.sync_workspace(workspace)
|
||||
skill = workspace / ".agents" / "skills" / "demo" / "SKILL.md"
|
||||
assert skill.read_text() == "demo v1"
|
||||
assert (workspace / "agents.md").read_text() == "agent rules v1"
|
||||
assert (workspace / "CLAUDE.md").is_symlink()
|
||||
assert (workspace / ".claude").is_symlink()
|
||||
assert report["added"] and not report["skipped"]
|
||||
assert _read_stamp(workspace)["version"] == "v0.0.0-test"
|
||||
|
||||
def test_second_run_is_noop(self, runtime: Path, workspace: Path) -> None:
|
||||
sync_mod.sync_workspace(workspace)
|
||||
report = sync_mod.sync_workspace(workspace)
|
||||
assert report == {"added": [], "updated": [], "skipped": [], "removed": []}
|
||||
|
||||
def test_upgrade_overwrites_untouched_file(
|
||||
self, runtime: Path, workspace: Path
|
||||
) -> None:
|
||||
sync_mod.sync_workspace(workspace)
|
||||
(runtime / "agents.md").write_text("agent rules v2")
|
||||
report = sync_mod.sync_workspace(workspace)
|
||||
assert (workspace / "agents.md").read_text() == "agent rules v2"
|
||||
assert "agents.md" in report["updated"]
|
||||
|
||||
def test_user_modified_file_is_skipped(
|
||||
self, runtime: Path, workspace: Path
|
||||
) -> None:
|
||||
sync_mod.sync_workspace(workspace)
|
||||
(workspace / "agents.md").write_text("my local edits")
|
||||
(runtime / "agents.md").write_text("agent rules v2")
|
||||
report = sync_mod.sync_workspace(workspace)
|
||||
assert (workspace / "agents.md").read_text() == "my local edits"
|
||||
assert "agents.md" in report["skipped"]
|
||||
|
||||
def test_removed_from_runtime_is_removed_from_workspace(
|
||||
self, runtime: Path, workspace: Path
|
||||
) -> None:
|
||||
sync_mod.sync_workspace(workspace)
|
||||
(runtime / ".agents" / "skills" / "demo" / "SKILL.md").unlink()
|
||||
report = sync_mod.sync_workspace(workspace)
|
||||
assert not (workspace / ".agents" / "skills" / "demo" / "SKILL.md").exists()
|
||||
assert report["removed"]
|
||||
|
||||
def test_git_checkout_refused_without_force(
|
||||
self, runtime: Path, workspace: Path
|
||||
) -> None:
|
||||
(workspace / ".git").mkdir()
|
||||
with pytest.raises(click.ClickException):
|
||||
sync_mod.sync_workspace(workspace)
|
||||
report = sync_mod.sync_workspace(workspace, force=True)
|
||||
assert report["added"]
|
||||
|
||||
def test_symlinked_skill_dir_is_dereferenced(
|
||||
self, runtime: Path, workspace: Path
|
||||
) -> None:
|
||||
# Skills living in contrib/ are linked into .agents/skills (relative
|
||||
# symlink); the workspace must receive real self-contained files.
|
||||
real = runtime / "contrib" / "mytoolkit" / ".claude" / "skills" / "mytoolkit"
|
||||
real.mkdir(parents=True)
|
||||
(real / "SKILL.md").write_text("toolkit skill v1")
|
||||
import os
|
||||
|
||||
rel_target = os.path.relpath(real, runtime / ".agents" / "skills")
|
||||
(runtime / ".agents" / "skills" / "mytoolkit").symlink_to(
|
||||
rel_target, target_is_directory=True
|
||||
)
|
||||
report = sync_mod.sync_workspace(workspace)
|
||||
copied = workspace / ".agents" / "skills" / "mytoolkit" / "SKILL.md"
|
||||
assert copied.is_file() and not copied.is_symlink()
|
||||
assert copied.read_text() == "toolkit skill v1"
|
||||
assert ".agents/skills/mytoolkit/SKILL.md" in report["added"]
|
||||
# Second run: content identical -> no spurious updates.
|
||||
assert sync_mod.sync_workspace(workspace) == {
|
||||
"added": [], "updated": [], "skipped": [], "removed": []
|
||||
}
|
||||
|
||||
def test_existing_real_managed_symlink_target_is_kept(
|
||||
self, runtime: Path, workspace: Path
|
||||
) -> None:
|
||||
# A pre-existing real CLAUDE.md must not be replaced by a symlink.
|
||||
(workspace / "CLAUDE.md").write_text("my own claude rules")
|
||||
sync_mod.sync_workspace(workspace)
|
||||
claude_md = workspace / "CLAUDE.md"
|
||||
assert not claude_md.is_symlink()
|
||||
assert claude_md.read_text() == "my own claude rules"
|
||||
assert (workspace / ".claude").is_symlink() # other links still made
|
||||
|
||||
|
||||
class TestCreateLauncher:
|
||||
@pytest.fixture()
|
||||
def fake_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
home = tmp_path / "home"
|
||||
(home / "Desktop").mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
return home
|
||||
|
||||
def test_macos_app_bundle_with_icon(
|
||||
self, runtime: Path, fake_home: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
(runtime / "assets" / "icon").mkdir(parents=True)
|
||||
(runtime / "assets" / "icon" / "XiaoheAgent.icns").write_bytes(b"icns")
|
||||
legacy_cmd = fake_home / "Desktop" / "XiaoheAgent.command"
|
||||
legacy_cmd.write_text("#!/bin/sh\n")
|
||||
legacy_app = fake_home / "Desktop" / "XiaoheAgent.app"
|
||||
legacy_app.mkdir()
|
||||
monkeypatch.setattr(
|
||||
sync_mod.os, "uname", lambda: type("U", (), {"sysname": "Darwin"})
|
||||
)
|
||||
sync_mod._create_launcher()
|
||||
app = fake_home / "Desktop" / "Xiaohe Agent.app"
|
||||
executable = app / "Contents" / "MacOS" / "XiaoheAgent"
|
||||
plist = (app / "Contents" / "Info.plist").read_text()
|
||||
assert "Xiaohe Agent" in plist
|
||||
assert (app / "Contents" / "Resources" / "XiaoheAgent.icns").is_file()
|
||||
assert executable.stat().st_mode & 0o111
|
||||
assert "xiaohe" in executable.read_text()
|
||||
assert not legacy_cmd.exists() # superseded .command removed
|
||||
assert not legacy_app.exists() # renamed from XiaoheAgent.app
|
||||
|
||||
def test_linux_desktop_entry_with_icon(
|
||||
self, runtime: Path, fake_home: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
(runtime / "assets" / "icon").mkdir(parents=True)
|
||||
(runtime / "assets" / "icon" / "xiaohe-icon-512.png").write_bytes(b"png")
|
||||
monkeypatch.setattr(
|
||||
sync_mod.os, "uname", lambda: type("U", (), {"sysname": "Linux"})
|
||||
)
|
||||
sync_mod._create_launcher()
|
||||
entry = (
|
||||
fake_home / ".local" / "share" / "applications" / "Xiaohe Agent.desktop"
|
||||
)
|
||||
text = entry.read_text()
|
||||
assert "Name=Xiaohe Agent" in text
|
||||
assert "Icon=xiaohe-agent" in text
|
||||
|
||||
|
||||
class TestInitCommand:
|
||||
@pytest.fixture()
|
||||
def fake_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
monkeypatch.setattr(sync_mod, "get_setting", lambda *a, **kw: "")
|
||||
monkeypatch.setattr(sync_mod, "set_setting", lambda *a, **kw: None)
|
||||
return home
|
||||
|
||||
def test_agent_tasks_created_from_template(
|
||||
self, runtime: Path, workspace: Path, fake_home: Path
|
||||
) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
template = runtime / "assistant" / "agent-tasks.template.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text("# task board template")
|
||||
result = CliRunner().invoke(sync_mod.init_cmd, [str(workspace)])
|
||||
assert result.exit_code == 0, result.output
|
||||
live = workspace / "assistant" / "agent-tasks.md"
|
||||
assert live.read_text() == "# task board template"
|
||||
assert (workspace / "tmp").is_dir() # skeleton dirs created
|
||||
|
||||
def test_existing_agent_tasks_untouched(
|
||||
self, runtime: Path, workspace: Path, fake_home: Path
|
||||
) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
template = runtime / "assistant" / "agent-tasks.template.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text("# task board template")
|
||||
live = workspace / "assistant" / "agent-tasks.md"
|
||||
live.parent.mkdir(parents=True)
|
||||
live.write_text("# my live board")
|
||||
result = CliRunner().invoke(sync_mod.init_cmd, [str(workspace)])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert live.read_text() == "# my live board"
|
||||
+17
-280
@@ -1,289 +1,26 @@
|
||||
"""Tests for myagents.commands.upgrade."""
|
||||
"""Tests for myagents.commands.upgrade (forwards to xiaohe upgrade)."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from myagents.commands import upgrade as up_mod
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
return home
|
||||
|
||||
|
||||
def _make_runtime(home: Path, version: str) -> Path:
|
||||
target = home / ".xiaohe" / "runtime" / version
|
||||
target.mkdir(parents=True)
|
||||
current = target.parent / "current"
|
||||
current.unlink(missing_ok=True)
|
||||
current.symlink_to(target)
|
||||
return target
|
||||
|
||||
|
||||
class TestParseVersion:
|
||||
def test_parses_quoted_version(self) -> None:
|
||||
assert up_mod._parse_version('BASE_URL="x"\nVERSION="v1.2.3"\n') == "v1.2.3"
|
||||
|
||||
def test_returns_none_on_garbage(self) -> None:
|
||||
assert up_mod._parse_version("no version here") is None
|
||||
|
||||
|
||||
class TestPrune:
|
||||
def test_keeps_two_newest(self, fake_home: Path) -> None:
|
||||
root = fake_home / ".xiaohe" / "runtime"
|
||||
for name in ("v1", "v2", "v3", "v4"):
|
||||
d = root / name
|
||||
d.mkdir(parents=True)
|
||||
up_mod._prune()
|
||||
remaining = sorted(p.name for p in root.iterdir())
|
||||
assert remaining == ["v3", "v4"]
|
||||
|
||||
|
||||
class TestUpgradeCommand:
|
||||
def test_already_latest_shortcircuits(
|
||||
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_make_runtime(fake_home, "v1.0.0")
|
||||
monkeypatch.setattr(
|
||||
up_mod, "_fetch_text", lambda *a: 'VERSION="v1.0.0"\n'
|
||||
)
|
||||
result = CliRunner().invoke(
|
||||
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Already up to date" in result.output
|
||||
def test_forwards_to_xiaohe(self) -> None:
|
||||
with (
|
||||
patch.object(up_mod.shutil, "which", return_value="/bin/xiaohe"),
|
||||
patch.object(up_mod.subprocess, "call", return_value=0) as call,
|
||||
):
|
||||
result = CliRunner().invoke(up_mod.upgrade_cmd, ["0.5.1", "--force"])
|
||||
assert result.exit_code == 0
|
||||
call.assert_called_once_with(
|
||||
["/bin/xiaohe", "upgrade", "0.5.1", "--force"]
|
||||
)
|
||||
|
||||
def test_missing_runtime_errors(self, fake_home: Path) -> None:
|
||||
result = CliRunner().invoke(
|
||||
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "install.sh" in result.output
|
||||
|
||||
def test_cancelled_by_user(
|
||||
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_make_runtime(fake_home, "v1.0.0")
|
||||
monkeypatch.setattr(
|
||||
up_mod, "_fetch_text", lambda *a: 'VERSION="v2.0.0"\n'
|
||||
)
|
||||
result = CliRunner().invoke(
|
||||
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"], input="n\n"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Cancelled" in result.output
|
||||
|
||||
|
||||
def _make_tarball_bytes() -> bytes:
|
||||
import tarfile
|
||||
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||||
data = b"#!/bin/sh\n"
|
||||
info = tarfile.TarInfo("workspace/setup.sh")
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _wire_upgrade(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
payload: bytes,
|
||||
sha: str,
|
||||
) -> None:
|
||||
import hashlib
|
||||
|
||||
def fake_fetch(url: str, user: str, password: str) -> str:
|
||||
if url.endswith("install.sh"):
|
||||
return 'VERSION="v2.0.0"\n'
|
||||
if url.endswith(".sha256"):
|
||||
return f"{sha} xiaohe-agent-latest.tar.gz\n"
|
||||
raise AssertionError(f"unexpected url {url}")
|
||||
|
||||
monkeypatch.setattr(up_mod, "_fetch_text", fake_fetch)
|
||||
monkeypatch.setattr(
|
||||
up_mod, "_download", lambda url, u, p, dest: dest.write_bytes(payload)
|
||||
)
|
||||
monkeypatch.setattr(up_mod, "_install_tools", lambda runtime: [])
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(up_mod, "get_workspace_root", lambda create=False: workspace)
|
||||
monkeypatch.setattr(
|
||||
up_mod,
|
||||
"sync_workspace",
|
||||
lambda ws: {"added": [], "updated": [], "skipped": [], "removed": []},
|
||||
)
|
||||
|
||||
|
||||
class TestUpgradeFlow:
|
||||
def test_downloads_verifies_and_switches(
|
||||
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
import hashlib
|
||||
|
||||
_make_runtime(fake_home, "v1.0.0")
|
||||
payload = _make_tarball_bytes()
|
||||
sha = hashlib.sha256(payload).hexdigest()
|
||||
_wire_upgrade(monkeypatch, tmp_path, payload, sha)
|
||||
result = CliRunner().invoke(
|
||||
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"], input="y\n"
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Upgraded: v1.0.0 -> v2.0.0" in result.output
|
||||
current = fake_home / ".xiaohe" / "runtime" / "current"
|
||||
assert current.resolve().name == "v2.0.0"
|
||||
assert (current / "setup.sh").is_file()
|
||||
|
||||
def test_checksum_mismatch_aborts_before_install(
|
||||
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
_make_runtime(fake_home, "v1.0.0")
|
||||
payload = _make_tarball_bytes()
|
||||
_wire_upgrade(monkeypatch, tmp_path, payload, "0" * 64)
|
||||
result = CliRunner().invoke(
|
||||
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"], input="y\n"
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Checksum mismatch" in result.output
|
||||
# Old runtime untouched, no new version dir.
|
||||
current = fake_home / ".xiaohe" / "runtime" / "current"
|
||||
assert current.resolve().name == "v1.0.0"
|
||||
assert not (fake_home / ".xiaohe" / "runtime" / "v2.0.0").exists()
|
||||
|
||||
|
||||
class TestVerifyChecksum:
|
||||
def test_accepts_matching(self, tmp_path: Path) -> None:
|
||||
import hashlib
|
||||
|
||||
blob = tmp_path / "f.tar.gz"
|
||||
blob.write_bytes(b"data")
|
||||
sha = hashlib.sha256(b"data").hexdigest()
|
||||
up_mod._verify_checksum(blob, f"{sha} f.tar.gz\n", "http://x/")
|
||||
|
||||
def test_rejects_mismatch_and_garbage(self, tmp_path: Path) -> None:
|
||||
import click
|
||||
|
||||
blob = tmp_path / "f.tar.gz"
|
||||
blob.write_bytes(b"data")
|
||||
with pytest.raises(click.ClickException, match="Checksum mismatch"):
|
||||
up_mod._verify_checksum(blob, f"{'0' * 64} f.tar.gz\n", "http://x/")
|
||||
with pytest.raises(click.ClickException, match="looks wrong"):
|
||||
up_mod._verify_checksum(blob, "not-a-checksum\n", "http://x/")
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self.headers = {"Content-Length": str(len(data))}
|
||||
self._buf = io.BytesIO(data)
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return self._buf.read(n)
|
||||
|
||||
def __enter__(self) -> "_FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class TestFetch:
|
||||
def test_fetch_text_sends_basic_auth(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def fake_urlopen(req, timeout=0):
|
||||
seen["auth"] = req.headers["Authorization"]
|
||||
return _FakeResponse(b"hello")
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
assert up_mod._fetch_text("http://x/", "u", "p") == "hello"
|
||||
assert seen["auth"].startswith("Basic ")
|
||||
|
||||
def test_401_maps_to_friendly_error(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import urllib.error
|
||||
|
||||
def fake_urlopen(req, timeout=0):
|
||||
raise urllib.error.HTTPError("http://x/", 401, "Unauthorized", {}, None)
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
with pytest.raises(Exception, match="401"):
|
||||
up_mod._fetch_text("http://x/", "u", "p")
|
||||
|
||||
def test_download_writes_file(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
payload = b"x" * 100_000
|
||||
monkeypatch.setattr(
|
||||
"urllib.request.urlopen",
|
||||
lambda req, timeout=0: _FakeResponse(payload),
|
||||
)
|
||||
dest = tmp_path / "out.tar.gz"
|
||||
up_mod._download("http://x/f", "u", "p", dest)
|
||||
assert dest.read_bytes() == payload
|
||||
|
||||
|
||||
class TestPipInstallFlags:
|
||||
def test_uses_break_system_packages_on_externally_managed(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import sysconfig
|
||||
|
||||
# Force the plain-pip branch regardless of how pytest was launched
|
||||
# (e.g. `uv run` sets VIRTUAL_ENV, which would select the uv branch).
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
em = tmp_path / "EXTERNALLY-MANAGED"
|
||||
em.write_text("[externally-managed]\n")
|
||||
monkeypatch.setattr(
|
||||
sysconfig, "get_path", lambda name: str(tmp_path) if name == "stdlib" else ""
|
||||
)
|
||||
seen: list[list[str]] = []
|
||||
monkeypatch.setattr(
|
||||
"subprocess.run",
|
||||
lambda cmd, **kw: seen.append(cmd)
|
||||
or type("R", (), {"returncode": 0, "stderr": ""})(),
|
||||
)
|
||||
up_mod._pip_install(tmp_path / "pkg")
|
||||
assert "--break-system-packages" in seen[0]
|
||||
|
||||
def test_skips_flag_without_externally_managed(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import sysconfig
|
||||
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr(
|
||||
sysconfig, "get_path", lambda name: str(tmp_path) if name == "stdlib" else ""
|
||||
)
|
||||
seen: list[list[str]] = []
|
||||
monkeypatch.setattr(
|
||||
"subprocess.run",
|
||||
lambda cmd, **kw: seen.append(cmd)
|
||||
or type("R", (), {"returncode": 0, "stderr": ""})(),
|
||||
)
|
||||
up_mod._pip_install(tmp_path / "pkg")
|
||||
assert "--break-system-packages" not in seen[0]
|
||||
|
||||
def test_prefers_uv_pip_inside_virtualenv(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("VIRTUAL_ENV", str(tmp_path / "venv"))
|
||||
monkeypatch.setattr(up_mod.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||
seen: list[list[str]] = []
|
||||
monkeypatch.setattr(
|
||||
"subprocess.run",
|
||||
lambda cmd, **kw: seen.append(cmd)
|
||||
or type("R", (), {"returncode": 0, "stderr": ""})(),
|
||||
)
|
||||
up_mod._pip_install(tmp_path / "pkg")
|
||||
assert seen[0][:3] == ["uv", "pip", "install"]
|
||||
assert "--break-system-packages" not in seen[0]
|
||||
def test_missing_xiaohe(self) -> None:
|
||||
with patch.object(up_mod.shutil, "which", return_value=None):
|
||||
result = CliRunner().invoke(up_mod.upgrade_cmd, [])
|
||||
assert result.exit_code != 0
|
||||
assert "xiaohe upgrade" in result.output
|
||||
|
||||
+17
-15
@@ -3,16 +3,14 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from myagents.commands import version as ver_mod
|
||||
|
||||
|
||||
class TestDescribeTree:
|
||||
def test_installed_runtime_tree(self) -> None:
|
||||
def test_legacy_runtime_tree(self) -> None:
|
||||
tree = Path("/home/u/.xiaohe/runtime/v1.2.3/contrib/myagents")
|
||||
mode, detail = ver_mod.describe_tree(tree)
|
||||
assert mode == "installed"
|
||||
assert mode == "legacy-runtime"
|
||||
assert detail == "v1.2.3"
|
||||
|
||||
def test_development_checkout(self, tmp_path: Path) -> None:
|
||||
@@ -33,16 +31,20 @@ class TestGitDescribe:
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
||||
"commit", "-q", "--allow-empty", "-m", "init"],
|
||||
cwd=repo, check=True,
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init",
|
||||
],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
result = ver_mod._git_describe(repo)
|
||||
assert "branch" in result
|
||||
|
||||
|
||||
class TestVersionCommand:
|
||||
def test_runs_and_reports(self) -> None:
|
||||
result = CliRunner().invoke(ver_mod.version_cmd)
|
||||
assert result.exit_code == 0
|
||||
assert "myagents" in result.output
|
||||
assert result != "unknown"
|
||||
|
||||
Reference in New Issue
Block a user