Reuses cli.update_check.check() for the version probe — extended with bypass_disabled=True so explicit user-typed self-upgrade is not silenced by AGNES_NO_UPDATE_CHECK (which is for the implicit warning loop). Install path: uv tool install --force when uv is on PATH; otherwise curl + pip via sys.executable (NOT system python3, NOT --user — both would land outside the agnes venv and silently no-op the upgrade). Smoke test execs the binary at the install-resolved path (uv tool dir joined with agnes-the-ai-analyst/bin/agnes, or sys.executable's sibling agnes for pip) — never via shutil.which, which can resolve a stale shadow on PATH and produce a false-positive smoke pass on the OLD version. Smoke also asserts --version output contains info.latest via PEP 440 Version() equality (so 0.40.0 does not falsely match 0.40.10). On smoke fail: rollback to last_known_good.json (written only after a previous run's smoke passed). Rollback rc is captured and surfaced on stderr if it also fails. First-ever upgrade or unrecoverable rollback prints the canonical bootstrap recovery: curl -fsSL <server>/cli/install.sh | bash. AGNES_SELF_UPGRADE_IN_PROGRESS=1 is set for the duration of the run and propagated to the smoke-test subprocess. Layer B's _check_version_headers honors the sentinel and skips the < min hard-stop, so an in-flight upgrade can never sys.exit(2) itself. --force invalidates the update_check cache BEFORE probing. --force + offline = exit 1 with explicit stderr (without --force, offline is silent). --quiet suppresses progress output but never gags failure stderr.
271 lines
11 KiB
Python
271 lines
11 KiB
Python
"""Tests for the CLI auto-update check (cli/update_check.py)."""
|
|
|
|
import json
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from cli.main import app
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def tmp_config(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("AGNES_CONFIG_DIR", str(tmp_path))
|
|
# Point CLI at a fake server so get_server_url() returns something stable.
|
|
monkeypatch.setenv("AGNES_SERVER", "http://server.test:8000")
|
|
yield tmp_path
|
|
|
|
|
|
def test_check_returns_none_when_disabled(tmp_config):
|
|
import os
|
|
os.environ["AGNES_NO_UPDATE_CHECK"] = "1"
|
|
try:
|
|
from cli import update_check
|
|
assert update_check.check("http://server.test:8000") is None
|
|
finally:
|
|
del os.environ["AGNES_NO_UPDATE_CHECK"]
|
|
|
|
|
|
def test_check_returns_none_when_server_url_missing(tmp_config):
|
|
from cli import update_check
|
|
assert update_check.check("") is None
|
|
assert update_check.check(None) is None # type: ignore[arg-type]
|
|
|
|
|
|
def test_check_bypass_disabled_overrides_env(monkeypatch, tmp_config):
|
|
"""`AGNES_NO_UPDATE_CHECK=1` silences the implicit warning loop, but
|
|
explicit callers (e.g. `agnes self-upgrade`) pass `bypass_disabled=True`
|
|
and must NOT become a silent no-op."""
|
|
from cli import update_check
|
|
|
|
monkeypatch.setenv("AGNES_NO_UPDATE_CHECK", "1")
|
|
payload = {
|
|
"version": "9.9.9",
|
|
"wheel_filename": "x.whl",
|
|
"download_url_path": "/cli/wheel/x.whl",
|
|
}
|
|
with patch("cli.update_check._installed_version", return_value="2.0.0"):
|
|
with patch("cli.update_check._fetch_latest", return_value=payload):
|
|
# Default: env var wins, returns None.
|
|
assert update_check.check("http://server.test") is None
|
|
# Bypass: env var ignored.
|
|
info = update_check.check("http://server.test", bypass_disabled=True)
|
|
assert info is not None and info.latest == "9.9.9"
|
|
|
|
|
|
def test_check_returns_none_when_installed_version_unknown(tmp_config):
|
|
from cli import update_check
|
|
with patch("cli.update_check._installed_version", return_value="unknown"):
|
|
assert update_check.check("http://server.test:8000") is None
|
|
|
|
|
|
def test_check_fresh_fetch_and_cache_write(tmp_config):
|
|
from cli import update_check
|
|
|
|
payload = {
|
|
"version": "2.1.0",
|
|
"wheel_filename": "agnes_the_ai_analyst-2.1.0-py3-none-any.whl",
|
|
"download_url_path": "/cli/wheel/agnes_the_ai_analyst-2.1.0-py3-none-any.whl",
|
|
}
|
|
with patch("cli.update_check._installed_version", return_value="2.0.0"):
|
|
with patch("cli.update_check._fetch_latest", return_value=payload):
|
|
info = update_check.check("http://server.test:8000")
|
|
|
|
assert info is not None
|
|
assert info.installed == "2.0.0"
|
|
assert info.latest == "2.1.0"
|
|
assert info.download_url == (
|
|
"http://server.test:8000/cli/wheel/agnes_the_ai_analyst-2.1.0-py3-none-any.whl"
|
|
)
|
|
assert info.is_outdated() is True
|
|
|
|
# Cache file was written and re-reading it returns the same latest.
|
|
cache = json.loads((tmp_config / "update_check.json").read_text())
|
|
assert cache["installed"] == "2.0.0"
|
|
assert cache["latest"] == "2.1.0"
|
|
|
|
|
|
def test_check_uses_cache_within_ttl(tmp_config):
|
|
"""Cached entry within 24h skips the network fetch."""
|
|
from cli import update_check
|
|
|
|
# Seed a fresh cache entry.
|
|
(tmp_config / "update_check.json").write_text(json.dumps({
|
|
"installed": "2.0.0",
|
|
"server_url": "http://server.test:8000",
|
|
"latest": "2.0.5",
|
|
"download_url": "http://server.test:8000/cli/wheel/agnes_the_ai_analyst-2.0.5-py3-none-any.whl",
|
|
"checked_at": __import__("time").time(), # now
|
|
}))
|
|
|
|
with patch("cli.update_check._installed_version", return_value="2.0.0"):
|
|
with patch("cli.update_check._fetch_latest") as mock_fetch:
|
|
info = update_check.check("http://server.test:8000")
|
|
|
|
assert mock_fetch.call_count == 0 # cache hit
|
|
assert info.latest == "2.0.5"
|
|
assert info.is_outdated() is True
|
|
|
|
|
|
def test_check_invalidates_cache_when_installed_version_changed(tmp_config):
|
|
"""User ran a fresh install after the cache was written — re-probe."""
|
|
from cli import update_check
|
|
|
|
# Seed cache claiming the installed version was 1.9.0.
|
|
(tmp_config / "update_check.json").write_text(json.dumps({
|
|
"installed": "1.9.0",
|
|
"server_url": "http://server.test:8000",
|
|
"latest": "2.0.0",
|
|
"download_url": "http://server.test:8000/cli/wheel/x.whl",
|
|
"checked_at": __import__("time").time(),
|
|
}))
|
|
|
|
payload = {"version": "2.1.0", "download_url_path": "/cli/wheel/y.whl"}
|
|
with patch("cli.update_check._installed_version", return_value="2.0.0"):
|
|
with patch("cli.update_check._fetch_latest", return_value=payload) as mock_fetch:
|
|
info = update_check.check("http://server.test:8000")
|
|
|
|
assert mock_fetch.call_count == 1 # cache was invalidated
|
|
assert info.latest == "2.1.0"
|
|
|
|
|
|
def test_check_handles_network_failure_silently(tmp_config):
|
|
"""A probe that errors out returns None; no exception leaks."""
|
|
from cli import update_check
|
|
with patch("cli.update_check._installed_version", return_value="2.0.0"):
|
|
with patch("cli.update_check._fetch_latest", return_value=None):
|
|
assert update_check.check("http://server.test:8000") is None
|
|
|
|
|
|
def test_negative_cache_avoids_reprobe_on_repeated_failure(tmp_config):
|
|
"""Two consecutive check() calls after a failed probe must fire the
|
|
network once — the second call hits the 5-minute negative cache."""
|
|
from cli import update_check
|
|
|
|
with patch("cli.update_check._installed_version", return_value="2.0.0"):
|
|
with patch("cli.update_check._fetch_latest", return_value=None) as mock_fetch:
|
|
assert update_check.check("http://server.test:8000") is None
|
|
# Second call within the negative-cache window.
|
|
assert update_check.check("http://server.test:8000") is None
|
|
|
|
assert mock_fetch.call_count == 1 # no re-probe
|
|
|
|
|
|
def test_negative_cache_expires_after_ttl(tmp_config):
|
|
"""After the negative TTL elapses, the probe fires again."""
|
|
import time
|
|
import json as _json
|
|
|
|
from cli import update_check
|
|
|
|
# Seed a stale negative-cache entry (older than 5min).
|
|
stale_ts = time.time() - (update_check._NEGATIVE_CACHE_TTL_SECONDS + 60)
|
|
(tmp_config / "update_check.json").write_text(_json.dumps({
|
|
"installed": "2.0.0",
|
|
"server_url": "http://server.test:8000",
|
|
"latest": None,
|
|
"download_url": None,
|
|
"checked_at": stale_ts,
|
|
}))
|
|
|
|
payload = {"version": "2.1.0", "download_url_path": "/cli/wheel/x.whl"}
|
|
with patch("cli.update_check._installed_version", return_value="2.0.0"):
|
|
with patch("cli.update_check._fetch_latest", return_value=payload) as mock_fetch:
|
|
info = update_check.check("http://server.test:8000")
|
|
|
|
assert mock_fetch.call_count == 1 # cache expired, refetch
|
|
assert info is not None
|
|
assert info.latest == "2.1.0"
|
|
|
|
|
|
def test_is_outdated_false_when_same_version(tmp_config):
|
|
from cli.update_check import UpdateInfo
|
|
info = UpdateInfo(installed="2.0.0", latest="2.0.0", download_url="…")
|
|
assert info.is_outdated() is False
|
|
|
|
|
|
def test_is_outdated_false_when_latest_unknown(tmp_config):
|
|
from cli.update_check import UpdateInfo
|
|
info = UpdateInfo(installed="2.0.0", latest=None, download_url=None)
|
|
assert info.is_outdated() is False
|
|
|
|
|
|
def test_is_outdated_true_when_installed_older(tmp_config):
|
|
from cli.update_check import UpdateInfo
|
|
info = UpdateInfo(installed="2.0.0", latest="2.1.0", download_url="…")
|
|
assert info.is_outdated() is True
|
|
|
|
|
|
def test_is_outdated_false_when_installed_newer_than_server(tmp_config):
|
|
"""After a server rollback the CLI may be ahead — don't prompt a downgrade."""
|
|
from cli.update_check import UpdateInfo
|
|
info = UpdateInfo(installed="2.1.0", latest="2.0.0", download_url="…")
|
|
assert info.is_outdated() is False
|
|
|
|
|
|
def test_is_outdated_uses_pep440_comparison(tmp_config):
|
|
"""`10.0.0 > 2.1.0` — must not be tripped by lexicographic string compare."""
|
|
from cli.update_check import UpdateInfo
|
|
newer_on_server = UpdateInfo(installed="2.1.0", latest="10.0.0", download_url="…")
|
|
older_on_server = UpdateInfo(installed="10.0.0", latest="2.1.0", download_url="…")
|
|
assert newer_on_server.is_outdated() is True
|
|
assert older_on_server.is_outdated() is False
|
|
|
|
|
|
def test_is_outdated_false_for_unparseable_strings(tmp_config):
|
|
"""Unparseable versions default to False — we'd rather miss an upgrade
|
|
hint than suggest a bogus downgrade."""
|
|
from cli.update_check import UpdateInfo
|
|
info = UpdateInfo(installed="nightly-abc", latest="nightly-def", download_url="…")
|
|
assert info.is_outdated() is False
|
|
|
|
|
|
def test_format_outdated_notice_drops_upgrade_line_when_no_download_url(tmp_config):
|
|
"""`download_url=None` must NOT produce literal "None" in the copy-pasteable command."""
|
|
from cli.update_check import UpdateInfo, format_outdated_notice
|
|
info = UpdateInfo(installed="2.0.0", latest="2.1.0", download_url=None)
|
|
msg = format_outdated_notice(info)
|
|
assert "None" not in msg
|
|
assert "uv tool install" not in msg
|
|
assert "2.0.0" in msg and "2.1.0" in msg
|
|
|
|
|
|
def test_format_outdated_notice_includes_upgrade_command_when_url_present(tmp_config):
|
|
from cli.update_check import UpdateInfo, format_outdated_notice
|
|
info = UpdateInfo(
|
|
installed="2.0.0",
|
|
latest="2.1.0",
|
|
download_url="http://s/cli/wheel/a-2.1.0-py3-none-any.whl",
|
|
)
|
|
msg = format_outdated_notice(info)
|
|
assert "uv tool install --force http://s/cli/wheel/a-2.1.0-py3-none-any.whl" in msg
|
|
|
|
|
|
class TestRootCallbackIntegration:
|
|
"""The root callback must not crash a command when the probe fails, and
|
|
must emit a stderr warning when the server advertises a newer version."""
|
|
|
|
def test_probe_failure_does_not_break_command(self, tmp_config):
|
|
with patch("cli.update_check.check", side_effect=RuntimeError("boom")):
|
|
result = runner.invoke(app, ["--help"])
|
|
assert result.exit_code == 0
|
|
|
|
def test_outdated_warning_is_emitted(self, tmp_config, capsys):
|
|
"""Unit-test the warning hook directly: `--help` is eager and bypasses
|
|
the callback body, so we test `_maybe_warn_outdated` itself, which
|
|
is what every real subcommand dispatch triggers."""
|
|
from cli.main import _maybe_warn_outdated
|
|
from cli.update_check import UpdateInfo
|
|
info = UpdateInfo(
|
|
installed="2.0.0",
|
|
latest="2.1.0",
|
|
download_url="http://server.test:8000/cli/wheel/x.whl",
|
|
)
|
|
with patch("cli.update_check.check", return_value=info):
|
|
_maybe_warn_outdated()
|
|
captured = capsys.readouterr()
|
|
assert "[update]" in captured.err
|
|
assert "2.1.0" in captured.err
|