Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
This commit is contained in:
Mathias Gesbert 2025-12-12 17:47:20 +01:00 committed by Mathias Gesbert
parent a340a721ea
commit 661588de0c
48 changed files with 1679 additions and 467 deletions

View file

@ -0,0 +1,17 @@
from __future__ import annotations
from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
class FakeUpdateCacheRepository(UpdateCacheRepository):
def __init__(self, update_cache: UpdateCache | None = None) -> None:
self.update_cache: UpdateCache | None = update_cache
async def get(self) -> UpdateCache | None:
return self.update_cache
async def set(self, update_cache: UpdateCache) -> None:
self.update_cache = update_cache

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from vibe.cli.update_notifier.ports.version_update_gateway import (
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayError,
)
class FakeVersionUpdateGateway(VersionUpdateGateway):
def __init__(
self,
update: VersionUpdate | None = None,
error: VersionUpdateGatewayError | None = None,
) -> None:
self._update: VersionUpdate | None = update
self._error = error
self.fetch_update_calls = 0
async def fetch_update(self) -> VersionUpdate | None:
self.fetch_update_calls += 1
if self._error is not None:
raise self._error
return self._update

View file

@ -0,0 +1,78 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
FileSystemUpdateCacheRepository,
)
from vibe.cli.update_notifier.ports.update_cache_repository import UpdateCache
@pytest.mark.asyncio
async def test_reads_cache_from_file_when_present(tmp_path: Path) -> None:
cache_file = tmp_path / "update_cache.json"
cache_file.write_text(
json.dumps({"latest_version": "1.2.3", "stored_at_timestamp": 1_700_000_000})
)
repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
cache = await repository.get()
assert cache is not None
assert cache.latest_version == "1.2.3"
assert cache.stored_at_timestamp == 1_700_000_000
@pytest.mark.asyncio
async def test_returns_none_when_cache_file_is_missing(tmp_path: Path) -> None:
repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
cache = await repository.get()
assert cache is None
@pytest.mark.asyncio
async def test_returns_none_when_cache_file_is_corrupted(tmp_path: Path) -> None:
cache_dir = tmp_path / ".vibe"
cache_dir.mkdir()
(cache_dir / "update_cache.json").write_text("{not-json")
repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
cache = await repository.get()
assert cache is None
@pytest.mark.asyncio
async def test_overwrites_existing_cache(tmp_path: Path) -> None:
cache_file = tmp_path / "update_cache.json"
cache_file.write_text(
json.dumps({"latest_version": "1.0.0", "stored_at_timestamp": 1_600_000_000})
)
repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
await repository.set(
UpdateCache(latest_version="1.1.0", stored_at_timestamp=1_700_200_000)
)
content = json.loads(cache_file.read_text())
assert content["latest_version"] == "1.1.0"
assert content["stored_at_timestamp"] == 1_700_200_000
@pytest.mark.asyncio
async def test_silently_ignores_errors_when_writing_cache_fails(tmp_path: Path) -> None:
cache_dir = tmp_path / ".vibe"
cache_dir.mkdir()
(cache_dir / "update_cache.json").mkdir()
repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
await repository.set(
UpdateCache(latest_version="1.2.0", stored_at_timestamp=1_700_300_000)
)
assert (cache_dir / "update_cache.json").is_dir()

View file

@ -5,10 +5,8 @@ from collections.abc import Callable
import httpx
import pytest
from vibe.cli.update_notifier.github_version_update_gateway import (
from vibe.cli.update_notifier import (
GitHubVersionUpdateGateway,
)
from vibe.cli.update_notifier.version_update_gateway import (
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
)

View file

@ -0,0 +1,157 @@
from __future__ import annotations
from collections.abc import Callable
import httpx
import pytest
from vibe.cli.update_notifier.adapters.pypi_version_update_gateway import (
PyPIVersionUpdateGateway,
)
from vibe.cli.update_notifier.ports.version_update_gateway import (
VersionUpdate,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
)
Handler = Callable[[httpx.Request], httpx.Response]
PYPI_API_URL = "https://pypi.org"
@pytest.mark.asyncio
async def test_retrieves_nothing_when_no_versions_are_available() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status_code=httpx.codes.OK, json={"versions": [], "files": []}
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
update = await gateway.fetch_update()
assert update is None
@pytest.mark.asyncio
async def test_retrieves_the_latest_non_yanked_version() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["Accept"] == "application/vnd.pypi.simple.v1+json"
assert request.url.path == "/simple/mistral-vibe/"
return httpx.Response(
status_code=httpx.codes.OK,
json={
"versions": ["1.0.0", "1.0.1", "1.0.2"],
"files": [
{
"filename": "mistral_vibe-1.0.0-py3-none-any.whl",
"yanked": False,
},
{"filename": "mistral_vibe-1.0.1-py3-none-any.whl", "yanked": True},
{
"filename": "mistral_vibe-1.0.2-py3-none-any.whl",
"yanked": False,
},
],
},
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
update = await gateway.fetch_update()
assert update == VersionUpdate(latest_version="1.0.2")
@pytest.mark.asyncio
async def test_retrieves_nothing_when_only_yanked_versions_are_available() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status_code=httpx.codes.OK,
json={
"versions": ["1.0.0"],
"files": [
{"filename": "mistral_vibe-1.0.0-py3-none-any.whl", "yanked": True}
],
},
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
update = await gateway.fetch_update()
assert update is None
@pytest.mark.asyncio
async def test_does_not_match_versions_by_substring() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status_code=httpx.codes.OK,
json={
"versions": ["1.0.1"],
"files": [
{
"filename": "mistral_vibe-1.0.10-py3-none-any.whl",
"yanked": False,
}
],
},
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
update = await gateway.fetch_update()
assert update is None
def _raise_connect_timeout(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectTimeout("boom", request=request)
@pytest.mark.parametrize(
("handler", "expected_cause", "expected_message"),
[
(
lambda _: httpx.Response(status_code=httpx.codes.NOT_FOUND),
VersionUpdateGatewayCause.NOT_FOUND,
None,
),
(
lambda _: httpx.Response(status_code=httpx.codes.FORBIDDEN),
VersionUpdateGatewayCause.FORBIDDEN,
None,
),
(
lambda _: httpx.Response(status_code=httpx.codes.INTERNAL_SERVER_ERROR),
VersionUpdateGatewayCause.ERROR_RESPONSE,
None,
),
(
lambda _: httpx.Response(status_code=httpx.codes.OK, content=b"{not-json"),
VersionUpdateGatewayCause.INVALID_RESPONSE,
None,
),
(_raise_connect_timeout, VersionUpdateGatewayCause.REQUEST_FAILED, None),
],
)
@pytest.mark.asyncio
async def test_retrieves_nothing_when_fetching_update_fails(
handler: Callable[[httpx.Request], httpx.Response],
expected_cause: VersionUpdateGatewayCause,
expected_message: str | None,
) -> None:
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
with pytest.raises(VersionUpdateGatewayError) as excinfo:
await gateway.fetch_update()
assert excinfo.value.cause == expected_cause
if expected_message is not None:
assert str(excinfo.value) == expected_message

View file

@ -1,16 +1,21 @@
from __future__ import annotations
import asyncio
import time
from typing import Protocol
import pytest
from textual.app import Notification
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.update_notifier.fake_version_update_gateway import (
from tests.update_notifier.adapters.fake_update_cache_repository import (
FakeUpdateCacheRepository,
)
from tests.update_notifier.adapters.fake_version_update_gateway import (
FakeVersionUpdateGateway,
)
from vibe.cli.update_notifier.version_update_gateway import (
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.update_notifier import (
UpdateCache,
VersionUpdate,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
@ -59,6 +64,7 @@ class VibeAppFactory(Protocol):
self,
*,
notifier: FakeVersionUpdateGateway,
update_cache_repository: FakeUpdateCacheRepository | None = None,
config: VibeConfig | None = None,
auto_approve: bool = False,
current_version: str = "0.1.0",
@ -67,9 +73,13 @@ class VibeAppFactory(Protocol):
@pytest.fixture
def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeAppFactory:
update_cache_repository = FakeUpdateCacheRepository()
def _make_app(
*,
notifier: FakeVersionUpdateGateway,
update_cache_repository: FakeUpdateCacheRepository
| None = update_cache_repository,
config: VibeConfig | None = None,
auto_approve: bool = False,
current_version: str = "0.1.0",
@ -78,6 +88,7 @@ def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeApp
config=config or vibe_config_with_update_checks_enabled,
auto_approve=auto_approve,
version_update_notifier=notifier,
update_cache_repository=update_cache_repository,
current_version=current_version,
)
@ -159,3 +170,52 @@ async def test_ui_does_not_invoke_gateway_nor_show_update_notification_when_upda
await _assert_no_notifications(app, pilot, timeout=0.3)
assert notifier.fetch_update_calls == 0
@pytest.mark.asyncio
async def test_ui_does_not_show_toast_when_update_is_known_in_recent_cache_already(
make_vibe_app: VibeAppFactory,
) -> None:
timestamp_two_hours_ago = int(time.time()) - 2 * 60 * 60
notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
update_cache = UpdateCache(
latest_version="0.2.0", stored_at_timestamp=timestamp_two_hours_ago
)
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
app = make_vibe_app(
notifier=notifier, update_cache_repository=update_cache_repository
)
async with app.run_test() as pilot:
await _assert_no_notifications(app, pilot, timeout=0.3)
assert notifier.fetch_update_calls == 0
@pytest.mark.asyncio
async def test_ui_does_show_toast_when_cache_entry_is_too_old(
make_vibe_app: VibeAppFactory,
) -> None:
timestamp_two_days_ago = int(time.time()) - 2 * 24 * 60 * 60
notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
update_cache = UpdateCache(
latest_version="0.2.0", stored_at_timestamp=timestamp_two_days_ago
)
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
app = make_vibe_app(
notifier=notifier, update_cache_repository=update_cache_repository
)
async with app.run_test() as pilot:
await pilot.pause(0.3)
notifications = list(app._notifications)
assert notifications
notification = notifications[-1]
assert notification.severity == "information"
assert notification.title == "Update available"
assert (
notification.message
== '0.1.0 => 0.2.0\nRun "uv tool upgrade mistral-vibe" to update'
)
assert notifier.fetch_update_calls == 1

View file

@ -2,18 +2,27 @@ from __future__ import annotations
import pytest
from vibe.cli.update_notifier.fake_version_update_gateway import (
from tests.update_notifier.adapters.fake_update_cache_repository import (
FakeUpdateCacheRepository,
)
from tests.update_notifier.adapters.fake_version_update_gateway import (
FakeVersionUpdateGateway,
)
from vibe.cli.update_notifier.version_update import (
VersionUpdateError,
is_version_update_available,
)
from vibe.cli.update_notifier.version_update_gateway import (
from vibe.cli.update_notifier import (
UpdateCache,
VersionUpdate,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
)
from vibe.cli.update_notifier.version_update import (
VersionUpdateError,
get_update_if_available,
)
@pytest.fixture
def current_timestamp() -> int:
return 1765278683
@pytest.mark.asyncio
@ -23,8 +32,10 @@ async def test_retrieves_the_latest_version_update_when_available() -> None:
update=VersionUpdate(latest_version=latest_update)
)
update = await is_version_update_available(
version_update_notifier, current_version="1.0.0"
update = await get_update_if_available(
version_update_notifier,
current_version="1.0.0",
update_cache_repository=FakeUpdateCacheRepository(),
)
assert update is not None
@ -39,8 +50,10 @@ async def test_retrieves_nothing_when_the_current_version_is_the_latest() -> Non
update=VersionUpdate(latest_version=latest_version)
)
update = await is_version_update_available(
version_update_notifier, current_version=current_version
update = await get_update_if_available(
version_update_notifier,
current_version=current_version,
update_cache_repository=FakeUpdateCacheRepository(),
)
assert update is None
@ -56,8 +69,10 @@ async def test_retrieves_nothing_when_the_current_version_is_greater_than_the_la
update=VersionUpdate(latest_version=latest_version)
)
update = await is_version_update_available(
version_update_notifier, current_version=current_version
update = await get_update_if_available(
version_update_notifier,
current_version=current_version,
update_cache_repository=FakeUpdateCacheRepository(),
)
assert update is None
@ -67,8 +82,10 @@ async def test_retrieves_nothing_when_the_current_version_is_greater_than_the_la
async def test_retrieves_nothing_when_no_version_is_available() -> None:
version_update_notifier = FakeVersionUpdateGateway(update=None)
update = await is_version_update_available(
version_update_notifier, current_version="1.0.0"
update = await get_update_if_available(
version_update_notifier,
current_version="1.0.0",
update_cache_repository=FakeUpdateCacheRepository(),
)
assert update is None
@ -80,8 +97,10 @@ async def test_retrieves_nothing_when_latest_version_is_invalid() -> None:
update=VersionUpdate(latest_version="invalid-version")
)
update = await is_version_update_available(
version_update_notifier, current_version="1.0.0"
update = await get_update_if_available(
version_update_notifier,
current_version="1.0.0",
update_cache_repository=FakeUpdateCacheRepository(),
)
assert update is None
@ -96,8 +115,10 @@ async def test_replaces_hyphens_with_plus_signs_in_latest_version_to_conform_wit
update=VersionUpdate(latest_version="1.6.1-jetbrains")
)
update = await is_version_update_available(
version_update_notifier, current_version="1.0.0"
update = await get_update_if_available(
version_update_notifier,
current_version="1.0.0",
update_cache_repository=FakeUpdateCacheRepository(),
)
assert update is not None
@ -110,8 +131,10 @@ async def test_retrieves_nothing_when_current_version_is_invalid() -> None:
update=VersionUpdate(latest_version="1.0.1")
)
update = await is_version_update_available(
version_update_notifier, current_version="invalid-version"
update = await get_update_if_available(
version_update_notifier,
current_version="invalid-version",
update_cache_repository=FakeUpdateCacheRepository(),
)
assert update is None
@ -139,8 +162,166 @@ async def test_raises_version_update_error(
)
with pytest.raises(VersionUpdateError) as excinfo:
await is_version_update_available(
version_update_notifier, current_version="1.0.0"
await get_update_if_available(
version_update_notifier,
current_version="1.0.0",
update_cache_repository=FakeUpdateCacheRepository(),
)
assert expected_message_substring in str(excinfo.value)
@pytest.mark.asyncio
async def test_notifies_and_updates_cache_when_repository_is_empty(
current_timestamp: int,
) -> None:
version_update_notifier = FakeVersionUpdateGateway(
update=VersionUpdate(latest_version="1.0.1")
)
update_cache_repository = FakeUpdateCacheRepository()
update = await get_update_if_available(
version_update_notifier,
current_version="1.0.0",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
)
assert update is not None
assert update.latest_version == "1.0.1"
assert update.should_notify is True
assert version_update_notifier.fetch_update_calls == 1
assert update_cache_repository.update_cache is not None
assert update_cache_repository.update_cache.latest_version == "1.0.1"
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
@pytest.mark.asyncio
async def test_does_not_notify_when_an_available_update_has_been_recently_cached(
current_timestamp: int,
) -> None:
version_update_notifier = FakeVersionUpdateGateway(
update=VersionUpdate(latest_version="1.0.1")
)
timestamp_twelve_hours_ago = current_timestamp - 12 * 60 * 60
update_cache = UpdateCache(
latest_version="1.0.1", stored_at_timestamp=timestamp_twelve_hours_ago
)
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
update = await get_update_if_available(
version_update_notifier,
current_version="1.0.0",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
)
assert update is not None
assert update.latest_version == "1.0.1"
assert update.should_notify is False
assert version_update_notifier.fetch_update_calls == 0
@pytest.mark.asyncio
async def test_retrieves_nothing_when_the_recently_cached_update_is_the_one_currently_in_use(
current_timestamp: int,
) -> None:
version_update_notifier = FakeVersionUpdateGateway(
update=VersionUpdate(latest_version="1.0.1")
)
timestamp_twelve_hours_ago = current_timestamp - 12 * 60 * 60
update_cache = UpdateCache(
latest_version="1.0.1", stored_at_timestamp=timestamp_twelve_hours_ago
)
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
update = await get_update_if_available(
version_update_notifier,
current_version="1.0.1",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
)
assert update is None
assert version_update_notifier.fetch_update_calls == 0
@pytest.mark.asyncio
async def test_retrieves_fresh_update_and_notifies_and_updates_cache_when_cache_is_not_fresh(
current_timestamp: int,
) -> None:
version_update_notifier = FakeVersionUpdateGateway(
update=VersionUpdate(latest_version="1.0.2")
)
timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
update_cache = UpdateCache(
latest_version="1.0.1", stored_at_timestamp=timestamp_two_days_ago
)
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
update = await get_update_if_available(
version_update_notifier,
current_version="1.0.0",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
)
assert update is not None
assert version_update_notifier.fetch_update_calls == 1
assert update.should_notify is True
assert update.latest_version == "1.0.2"
assert update_cache_repository.update_cache is not None
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
assert update_cache_repository.update_cache.latest_version == "1.0.2"
@pytest.mark.asyncio
async def test_updates_cache_timestamp_with_current_version_when_no_update_is_available(
current_timestamp: int,
) -> None:
version_update_notifier = FakeVersionUpdateGateway(update=None)
timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
update_cache = UpdateCache(
latest_version="1.0.0", stored_at_timestamp=timestamp_two_days_ago
)
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
update = await get_update_if_available(
version_update_notifier,
current_version="1.0.0",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
)
assert update is None
assert version_update_notifier.fetch_update_calls == 1
assert update_cache_repository.update_cache is not None
assert update_cache_repository.update_cache.latest_version == "1.0.0"
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
@pytest.mark.asyncio
async def test_updates_cache_timestamp_with_current_version_when_gateway_errors(
current_timestamp: int,
) -> None:
version_update_notifier = FakeVersionUpdateGateway(
error=VersionUpdateGatewayError(cause=VersionUpdateGatewayCause.ERROR_RESPONSE)
)
timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
update_cache = UpdateCache(
latest_version="1.0.0", stored_at_timestamp=timestamp_two_days_ago
)
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
with pytest.raises(VersionUpdateError):
await get_update_if_available(
version_update_notifier,
current_version="1.0.0",
update_cache_repository=update_cache_repository,
get_current_timestamp=lambda: current_timestamp,
)
assert version_update_notifier.fetch_update_calls == 1
assert update_cache_repository.update_cache is not None
assert update_cache_repository.update_cache.latest_version == "1.0.0"
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp