## Summary - On every CLI command invocation (except `update`/`upgrade`/`mcp`), a detached subprocess checks PyPI for newer versions (throttled to once per 4 hours) and caches the result at `~/.arcade/update_cache.json` - On the next invocation, if a newer version is known, a yellow one-liner notification is printed suggesting `arcade update` - Respects `ARCADE_DISABLE_AUTOUPDATE=1` environment variable to opt out entirely <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a background PyPI version check that spawns detached subprocesses and may print update notifications on most CLI invocations; mistakes could impact CLI reliability or corrupt MCP stdio output (mitigated by explicit command exclusions). > > **Overview** > Adds `arcade update` (and hidden `arcade upgrade` alias) to self-upgrade the `arcade-mcp` CLI by detecting the original install method (`uv tool`, `pipx`, `uv pip`, or `pip`) and running the appropriate upgrade command. > > Introduces a **throttled background update check** on most CLI invocations: a detached subprocess queries PyPI, writes `~/.arcade/update_cache.json`, and on subsequent runs prints a one-line notification when a newer version is cached; this is disabled via `ARCADE_DISABLE_AUTOUPDATE=1` and explicitly skipped for `update`/`upgrade`/`mcp` to avoid MCP stdio output corruption. > > Bumps the package version to `1.13.0`, adds a `packaging` dependency, and includes comprehensive tests covering PyPI/yanked/prerelease handling, install-method detection, caching, and callback integration. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2d9646ecc2211e8cfecd6e4901d14b1f5b7bb306. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
622 lines
28 KiB
Python
622 lines
28 KiB
Python
"""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()
|