agnes-the-ai-analyst/tests/test_cli_update_check.py
ZdenekSrotyr 630e224578 feat(cli): add agnes self-upgrade with smoke test + rollback
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.
2026-05-06 23:23:23 +02:00

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