"""Tests for the `arcade update` CLI command.""" import json import os import sys import time from unittest.mock import MagicMock, patch import pytest from arcade_cli.main import cli from arcade_cli.update import ( InstallMethod, UpdateCache, _background_check, check_and_notify, detect_install_method, fetch_latest_pypi_version, fork_background_check, read_update_cache, should_check_for_update, write_update_cache, ) from typer.testing import CliRunner runner = CliRunner() PACKAGE_NAME = "arcade-mcp" # --------------------------------------------------------------------------- # Unit tests for fetch_latest_pypi_version # --------------------------------------------------------------------------- class TestFetchLatestPypiVersion: def test_returns_version_on_success(self) -> None: mock_response = MagicMock() mock_response.status = 200 mock_response.read.return_value = json.dumps({ "info": {"version": "2.0.0"}, "releases": {"2.0.0": [{"filename": "a.whl", "yanked": False}]}, }).encode() mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) with patch("arcade_cli.update.urlopen", return_value=mock_response): assert fetch_latest_pypi_version() == "2.0.0" def test_falls_back_to_stable_release_when_latest_is_prerelease(self) -> None: """When info.version is a pre-release, return the highest stable from releases.""" mock_response = MagicMock() mock_response.status = 200 payload = json.dumps({ "info": {"version": "2.0.0rc1"}, "releases": { "1.9.0": [{"filename": "a.whl", "yanked": False}], "2.0.0rc1": [{"filename": "b.whl", "yanked": False}], "1.10.0": [{"filename": "c.whl", "yanked": False}], }, }).encode() mock_response.read.return_value = payload mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) with patch("arcade_cli.update.urlopen", return_value=mock_response): assert fetch_latest_pypi_version() == "1.10.0" def test_returns_none_when_only_prereleases_exist(self) -> None: """When all releases are pre-releases, return None.""" mock_response = MagicMock() mock_response.status = 200 payload = json.dumps({ "info": {"version": "2.0.0rc1"}, "releases": {"2.0.0a1": [], "2.0.0rc1": []}, }).encode() mock_response.read.return_value = payload mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) with patch("arcade_cli.update.urlopen", return_value=mock_response): assert fetch_latest_pypi_version() is None def test_skips_yanked_releases(self) -> None: """Yanked releases should not be returned even if they are the highest stable.""" mock_response = MagicMock() mock_response.status = 200 payload = json.dumps({ "info": {"version": "2.0.0rc1"}, "releases": { "1.9.0": [{"filename": "a.whl", "yanked": False}], "1.10.0": [{"filename": "b.whl", "yanked": True}], "2.0.0rc1": [{"filename": "c.whl", "yanked": False}], }, }).encode() mock_response.read.return_value = payload mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) with patch("arcade_cli.update.urlopen", return_value=mock_response): # 1.10.0 is yanked, so should fall back to 1.9.0 assert fetch_latest_pypi_version() == "1.9.0" def test_falls_back_when_latest_stable_is_yanked(self) -> None: """When info.version is stable but yanked, fall back to scanning releases.""" mock_response = MagicMock() mock_response.status = 200 payload = json.dumps({ "info": {"version": "2.0.0"}, "releases": { "1.9.0": [{"filename": "a.whl", "yanked": False}], "2.0.0": [{"filename": "b.whl", "yanked": True}], }, }).encode() mock_response.read.return_value = payload mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) with patch("arcade_cli.update.urlopen", return_value=mock_response): # 2.0.0 is yanked, so should fall back to 1.9.0 assert fetch_latest_pypi_version() == "1.9.0" def test_returns_none_on_http_error(self) -> None: with patch("arcade_cli.update.urlopen", side_effect=Exception("network error")): assert fetch_latest_pypi_version() is None # --------------------------------------------------------------------------- # Unit tests for detect_install_method # --------------------------------------------------------------------------- class TestDetectInstallMethod: def test_detects_uv_tool(self) -> None: """If `uv tool list` output contains the package name, method is UV_TOOL.""" uv_tool_output = "arcade-mcp v1.12.1\n- arcade\n- arcade-mcp\nother-package v0.1.0\n" with patch("arcade_cli.update.shutil") as mock_shutil: mock_shutil.which.return_value = "/usr/local/bin/uv" with patch("arcade_cli.update.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout=uv_tool_output) assert detect_install_method() == InstallMethod.UV_TOOL def test_detects_pipx(self) -> None: """If uv tool doesn't have it but pipx does, method is PIPX.""" def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock: if cmd[0] == "uv": return MagicMock(returncode=0, stdout="other-package v1.0.0\n") if cmd[0] == "pipx": return MagicMock(returncode=0, stdout=f" package {PACKAGE_NAME} 1.12.1") return MagicMock(returncode=1, stdout="") with patch("arcade_cli.update.shutil") as mock_shutil: mock_shutil.which.side_effect = lambda name: f"/usr/local/bin/{name}" with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect): assert detect_install_method() == InstallMethod.PIPX def test_falls_back_to_uv_pip_when_uv_available(self) -> None: """If uv is available but package not in uv tool or pipx, use UV_PIP.""" def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock: # uv tool list / pipx list don't have it if "tool" in cmd: return MagicMock(returncode=0, stdout="other-package v1.0.0\n") # uv pip show confirms the package is in the current environment if "show" in cmd: return MagicMock(returncode=0, stdout=f"Name: {PACKAGE_NAME}\nVersion: 1.0.0\n") return MagicMock(returncode=0, stdout="other-package v1.0.0\n") def which_side_effect(name: str) -> str | None: if name == "uv": return "/usr/local/bin/uv" return None # pipx not available with patch("arcade_cli.update.shutil") as mock_shutil: mock_shutil.which.side_effect = which_side_effect with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect): assert detect_install_method() == InstallMethod.UV_PIP def test_falls_back_to_pip_when_uv_pip_show_fails(self) -> None: """If uv is on PATH but package is not in the current env, fall back to PIP.""" def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock: if "tool" in cmd: return MagicMock(returncode=0, stdout="other-package v1.0.0\n") if "show" in cmd: return MagicMock(returncode=1, stdout="") return MagicMock(returncode=0, stdout="") def which_side_effect(name: str) -> str | None: if name == "uv": return "/usr/local/bin/uv" return None with patch("arcade_cli.update.shutil") as mock_shutil: mock_shutil.which.side_effect = which_side_effect with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect): assert detect_install_method() == InstallMethod.PIP def test_does_not_false_positive_on_prefix_match(self) -> None: """arcade-mcp-server should NOT be detected as arcade-mcp in uv tool list.""" uv_tool_output = "arcade-mcp-server v1.0.0\n- arcade-mcp-server\n" def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock: if "tool" in cmd: return MagicMock(returncode=0, stdout=uv_tool_output) # uv pip show confirms the package IS in the current env if "show" in cmd: return MagicMock(returncode=0, stdout=f"Name: {PACKAGE_NAME}\nVersion: 1.0.0\n") return MagicMock(returncode=0, stdout="") with patch("arcade_cli.update.shutil") as mock_shutil: mock_shutil.which.return_value = "/usr/local/bin/uv" with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect): # Should NOT detect UV_TOOL since only arcade-mcp-server is in uv tool list # but should detect UV_PIP since uv pip show confirms the package assert detect_install_method() == InstallMethod.UV_PIP def test_falls_back_to_pip(self) -> None: """If neither uv nor pipx is available, fall back to PIP.""" with patch("arcade_cli.update.shutil") as mock_shutil: mock_shutil.which.return_value = None # nothing on PATH assert detect_install_method() == InstallMethod.PIP # --------------------------------------------------------------------------- # Integration tests for the `arcade update` CLI command # --------------------------------------------------------------------------- class TestUpdateCommand: def test_already_up_to_date(self) -> None: with ( patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.12.1"), patch("arcade_cli.update.metadata") as mock_meta, ): mock_meta.version.return_value = "1.12.1" result = runner.invoke(cli, ["update"]) assert result.exit_code == 0 assert "up to date" in result.output.lower() def test_update_available_uv_tool(self) -> None: with ( patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"), patch("arcade_cli.update.metadata") as mock_meta, patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.UV_TOOL), patch("arcade_cli.update.subprocess.run") as mock_run, ): mock_meta.version.return_value = "1.12.1" mock_run.return_value = MagicMock(returncode=0) result = runner.invoke(cli, ["update"]) assert result.exit_code == 0 mock_run.assert_called_once() cmd = mock_run.call_args[0][0] assert cmd == ["uv", "tool", "upgrade", PACKAGE_NAME] def test_update_available_pipx_shows_warning(self) -> None: """Non-uv-tool methods should show a warning recommending uv tool install.""" with ( patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"), patch("arcade_cli.update.metadata") as mock_meta, patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.PIPX), patch("arcade_cli.update.subprocess.run") as mock_run, ): mock_meta.version.return_value = "1.12.1" mock_run.return_value = MagicMock(returncode=0) result = runner.invoke(cli, ["update"]) assert result.exit_code == 0 assert "recommend" in result.output.lower() assert "uv tool" in result.output.lower() def test_update_available_uv_pip_shows_warning(self) -> None: with ( patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"), patch("arcade_cli.update.metadata") as mock_meta, patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.UV_PIP), patch("arcade_cli.update.subprocess.run") as mock_run, ): mock_meta.version.return_value = "1.12.1" mock_run.return_value = MagicMock(returncode=0) result = runner.invoke(cli, ["update"]) assert result.exit_code == 0 assert "recommend" in result.output.lower() assert "uv tool" in result.output.lower() cmd = mock_run.call_args[0][0] assert cmd == ["uv", "pip", "install", "--upgrade", "--python", sys.executable, PACKAGE_NAME] def test_update_available_pip_shows_warning(self) -> None: with ( patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"), patch("arcade_cli.update.metadata") as mock_meta, patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.PIP), patch("arcade_cli.update.subprocess.run") as mock_run, ): mock_meta.version.return_value = "1.12.1" mock_run.return_value = MagicMock(returncode=0) result = runner.invoke(cli, ["update"]) assert result.exit_code == 0 assert "recommend" in result.output.lower() assert "uv tool" in result.output.lower() cmd = mock_run.call_args[0][0] assert cmd == ["pip", "install", "--upgrade", PACKAGE_NAME] def test_upgrade_alias_works(self) -> None: """The `arcade upgrade` alias should behave identically.""" with ( patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.12.1"), patch("arcade_cli.update.metadata") as mock_meta, ): mock_meta.version.return_value = "1.12.1" result = runner.invoke(cli, ["upgrade"]) assert result.exit_code == 0 assert "up to date" in result.output.lower() def test_pypi_fetch_failure(self) -> None: with patch("arcade_cli.update.fetch_latest_pypi_version", return_value=None): result = runner.invoke(cli, ["update"]) assert result.exit_code == 0 assert "could not check" in result.output.lower() def test_upgrade_command_failure_shows_manual_instructions(self) -> None: with ( patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"), patch("arcade_cli.update.metadata") as mock_meta, patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.UV_TOOL), patch("arcade_cli.update.subprocess.run") as mock_run, ): mock_meta.version.return_value = "1.12.1" mock_run.return_value = MagicMock(returncode=1) result = runner.invoke(cli, ["update"]) assert result.exit_code == 0 output = result.output.lower() # Should show preferred method and alternatives assert "uv tool upgrade" in output assert "alternatives" in output # --------------------------------------------------------------------------- # Unit tests for UpdateCache and cache I/O # --------------------------------------------------------------------------- class TestUpdateCache: def test_read_cache_returns_none_when_file_missing(self, tmp_path: pytest.TempPathFactory) -> None: assert read_update_cache(str(tmp_path / "nonexistent.json")) is None def test_read_cache_returns_none_on_corrupt_json(self, tmp_path: pytest.TempPathFactory) -> None: cache_file = tmp_path / "update_cache.json" cache_file.write_text("not valid json{{{") assert read_update_cache(str(cache_file)) is None def test_write_and_read_cache_roundtrip(self, tmp_path: pytest.TempPathFactory) -> None: cache_file = str(tmp_path / "update_cache.json") cache = UpdateCache(latest_version="2.0.0", checked_at=1000.0) write_update_cache(cache_file, cache) result = read_update_cache(cache_file) assert result is not None assert result.latest_version == "2.0.0" assert result.checked_at == 1000.0 def test_read_cache_ignores_unknown_fields(self, tmp_path: pytest.TempPathFactory) -> None: cache_file = tmp_path / "update_cache.json" cache_file.write_text(json.dumps({ "latest_version": "2.0.0", "checked_at": 1000.0, "unknown_field": "should be ignored", })) result = read_update_cache(str(cache_file)) assert result is not None assert result.latest_version == "2.0.0" # --------------------------------------------------------------------------- # Unit tests for should_check_for_update # --------------------------------------------------------------------------- class TestShouldCheckForUpdate: def test_returns_true_when_no_cache(self) -> None: assert should_check_for_update(None) is True def test_returns_true_when_cache_expired(self) -> None: old_cache = UpdateCache(latest_version="1.0.0", checked_at=time.time() - 5 * 3600) assert should_check_for_update(old_cache) is True def test_returns_false_when_cache_fresh(self) -> None: fresh_cache = UpdateCache(latest_version="1.0.0", checked_at=time.time() - 60) assert should_check_for_update(fresh_cache) is False def test_returns_false_when_disabled_env_var(self) -> None: with patch.dict(os.environ, {"ARCADE_DISABLE_AUTOUPDATE": "1"}): assert should_check_for_update(None) is False # --------------------------------------------------------------------------- # Unit tests for fork_background_check # --------------------------------------------------------------------------- class TestForkBackgroundCheck: def test_background_check_import_string_is_valid(self) -> None: """The hardcoded import string in fork_background_check must resolve to the real function.""" import importlib import inspect source = inspect.getsource(fork_background_check) # Extract the import string from the "-c" argument assert "from arcade_cli.update import _background_check; _background_check()" in source # Verify the function is actually importable at that path mod = importlib.import_module("arcade_cli.update") assert hasattr(mod, "_background_check") and callable(mod._background_check) def test_spawns_detached_subprocess_unix(self) -> None: """On Unix, spawns a detached subprocess with start_new_session=True.""" with ( patch("arcade_cli.update.read_update_cache", return_value=None), patch("arcade_cli.update.should_check_for_update", return_value=True), patch("arcade_cli.update.sys") as mock_sys, patch("arcade_cli.update.subprocess.Popen") as mock_popen, ): mock_sys.platform = "darwin" mock_sys.executable = "/usr/bin/python3" fork_background_check() mock_popen.assert_called_once() call_kwargs = mock_popen.call_args[1] assert call_kwargs.get("start_new_session") is True assert call_kwargs.get("close_fds") is True def test_spawns_detached_subprocess_windows(self) -> None: """On Windows, spawns a subprocess with creationflags instead of start_new_session.""" with ( patch("arcade_cli.update.read_update_cache", return_value=None), patch("arcade_cli.update.should_check_for_update", return_value=True), patch("arcade_cli.update.sys") as mock_sys, patch("arcade_cli.update.subprocess.Popen") as mock_popen, patch("arcade_cli.update.get_windows_no_window_creationflags", return_value=0x08000200), patch("arcade_cli.update.build_windows_hidden_startupinfo", return_value=None), ): mock_sys.platform = "win32" mock_sys.executable = "python.exe" fork_background_check() mock_popen.assert_called_once() call_kwargs = mock_popen.call_args[1] assert "start_new_session" not in call_kwargs assert call_kwargs.get("creationflags") == 0x08000200 assert call_kwargs.get("close_fds") is True def test_does_not_spawn_when_check_not_needed(self) -> None: """When cache is fresh, no subprocess is spawned.""" fresh_cache = UpdateCache(latest_version="1.0.0", checked_at=time.time()) with ( patch("arcade_cli.update.read_update_cache", return_value=fresh_cache), patch("arcade_cli.update.should_check_for_update", return_value=False), patch("arcade_cli.update.subprocess.Popen") as mock_popen, ): fork_background_check() mock_popen.assert_not_called() def test_swallows_exceptions(self) -> None: """Popen raising an exception should not crash.""" with ( patch("arcade_cli.update.read_update_cache", return_value=None), patch("arcade_cli.update.should_check_for_update", return_value=True), patch("arcade_cli.update.subprocess.Popen", side_effect=OSError("spawn failed")), ): # Should not raise fork_background_check() # --------------------------------------------------------------------------- # Unit tests for check_and_notify # --------------------------------------------------------------------------- class TestCheckAndNotify: def test_prints_notification_when_update_available(self) -> None: cache = UpdateCache(latest_version="2.0.0", checked_at=time.time()) with ( patch("arcade_cli.update.read_update_cache", return_value=cache), patch("arcade_cli.update.metadata") as mock_meta, patch("arcade_cli.update.fork_background_check"), patch("arcade_cli.update.console") as mock_console, ): mock_meta.version.return_value = "1.0.0" check_and_notify() output = mock_console.print.call_args[0][0] assert "update available" in output.lower() assert "arcade update" in output.lower() assert mock_console.print.call_args[1]["style"] == "yellow" def test_no_notification_when_up_to_date(self) -> None: cache = UpdateCache(latest_version="1.0.0", checked_at=time.time()) with ( patch("arcade_cli.update.read_update_cache", return_value=cache), patch("arcade_cli.update.metadata") as mock_meta, patch("arcade_cli.update.fork_background_check"), patch("arcade_cli.update.console") as mock_console, ): mock_meta.version.return_value = "1.0.0" check_and_notify() mock_console.print.assert_not_called() def test_no_notification_when_no_cache(self) -> None: with ( patch("arcade_cli.update.read_update_cache", return_value=None), patch("arcade_cli.update.fork_background_check"), patch("arcade_cli.update.console") as mock_console, ): check_and_notify() mock_console.print.assert_not_called() def test_no_notification_when_disabled(self) -> None: with ( patch.dict(os.environ, {"ARCADE_DISABLE_AUTOUPDATE": "1"}), patch("arcade_cli.update.read_update_cache") as mock_read, patch("arcade_cli.update.fork_background_check") as mock_fork, patch("arcade_cli.update.console") as mock_console, ): check_and_notify() mock_read.assert_not_called() mock_fork.assert_not_called() mock_console.print.assert_not_called() def test_forks_background_check(self) -> None: with ( patch("arcade_cli.update.read_update_cache", return_value=None), patch("arcade_cli.update.fork_background_check") as mock_fork, ): check_and_notify() mock_fork.assert_called_once() # --------------------------------------------------------------------------- # Unit tests for _background_check # --------------------------------------------------------------------------- class TestBackgroundCheck: def test_updates_timestamp_when_fetch_returns_none( self, tmp_path: pytest.TempPathFactory ) -> None: """When fetch returns None, the cache timestamp is still updated to throttle retries.""" cache_path = str(tmp_path / "update_cache.json") with ( patch("arcade_cli.update.fetch_latest_pypi_version", return_value=None), patch("arcade_cli.update.UPDATE_CACHE_PATH", cache_path), ): _background_check() result = read_update_cache(cache_path) assert result is not None assert result.latest_version == "" assert result.checked_at > 0 def test_preserves_cached_version_when_fetch_fails( self, tmp_path: pytest.TempPathFactory ) -> None: """When fetch fails but a previous version is cached, preserve it.""" cache_path = str(tmp_path / "update_cache.json") old_cache = UpdateCache(latest_version="1.5.0", checked_at=1000.0) write_update_cache(cache_path, old_cache) with ( patch("arcade_cli.update.fetch_latest_pypi_version", return_value=None), patch("arcade_cli.update.UPDATE_CACHE_PATH", cache_path), ): _background_check() result = read_update_cache(cache_path) assert result is not None assert result.latest_version == "1.5.0" assert result.checked_at > old_cache.checked_at def test_caches_stable_release(self, tmp_path: pytest.TempPathFactory) -> None: """Stable versions from PyPI should be cached.""" cache_path = str(tmp_path / "update_cache.json") with ( patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"), patch("arcade_cli.update.UPDATE_CACHE_PATH", cache_path), ): _background_check() result = read_update_cache(cache_path) assert result is not None assert result.latest_version == "2.0.0" # --------------------------------------------------------------------------- # Tests for main_callback integration with check_and_notify # --------------------------------------------------------------------------- class TestMainCallbackUpdateNotification: def test_main_callback_calls_check_and_notify(self) -> None: """Running a non-update command triggers check_and_notify.""" with patch("arcade_cli.main.check_and_notify") as mock_notify: # Use 'show' as a public command that goes through main_callback runner.invoke(cli, ["show", "--help"]) mock_notify.assert_called_once() def test_main_callback_skips_check_for_update_command(self) -> None: """Running `arcade update` should NOT trigger check_and_notify.""" with ( patch("arcade_cli.main.check_and_notify") as mock_notify, patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.0.0"), patch("arcade_cli.update.metadata") as mock_meta, ): mock_meta.version.return_value = "1.0.0" runner.invoke(cli, ["update"]) mock_notify.assert_not_called() def test_main_callback_skips_check_for_upgrade_command(self) -> None: """Running `arcade upgrade` should NOT trigger check_and_notify.""" with ( patch("arcade_cli.main.check_and_notify") as mock_notify, patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.0.0"), patch("arcade_cli.update.metadata") as mock_meta, ): mock_meta.version.return_value = "1.0.0" runner.invoke(cli, ["upgrade"]) mock_notify.assert_not_called() def test_main_callback_skips_check_for_mcp_command(self) -> None: """Running `arcade mcp` should NOT trigger check_and_notify (would corrupt stdio).""" with patch("arcade_cli.main.check_and_notify") as mock_notify: runner.invoke(cli, ["mcp", "--help"]) mock_notify.assert_not_called()