Initial commit
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Laure Hugo <laure.hugo@mistral.ai> Co-Authored-By: Benjamin Trom <benjamin.trom@mistral.ai> Co-Authored-By: Mathias Gesbert <mathias.gesbert@ext.mistral.ai> Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai> Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai> Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-Authored-By: Valentin Berard <val@mistral.ai> Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
commit
fa15fc977b
200 changed files with 30484 additions and 0 deletions
249
tests/update_notifier/test_github_version_update_gateway.py
Normal file
249
tests/update_notifier/test_github_version_update_gateway.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from vibe.cli.update_notifier.github_version_update_gateway import (
|
||||
GitHubVersionUpdateGateway,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update_gateway import (
|
||||
VersionUpdateGatewayCause,
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
|
||||
Handler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
GITHUB_API_URL = "https://api.github.com"
|
||||
|
||||
|
||||
def _raise_connect_timeout(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectTimeout("boom", request=request)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieves_latest_version_when_available() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers.get("Authorization") == "Bearer token"
|
||||
return httpx.Response(
|
||||
status_code=httpx.codes.OK,
|
||||
json=[{"tag_name": "v1.2.3", "prerelease": False, "draft": False}],
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url=GITHUB_API_URL
|
||||
) as client:
|
||||
notifier = GitHubVersionUpdateGateway(
|
||||
"owner", "repo", token="token", client=client
|
||||
)
|
||||
update = await notifier.fetch_update()
|
||||
|
||||
assert update is not None
|
||||
assert update.latest_version == "1.2.3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strips_uppercase_prefix_from_tag_name() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=httpx.codes.OK,
|
||||
json=[{"tag_name": "V0.9.0", "prerelease": False, "draft": False}],
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url=GITHUB_API_URL
|
||||
) as client:
|
||||
notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
|
||||
update = await notifier.fetch_update()
|
||||
|
||||
assert update is not None
|
||||
assert update.latest_version == "0.9.0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_considers_no_update_available_when_no_releases_are_found() -> None:
|
||||
"""If the repository cannot be accessed (e.g. invalid token), the response will be 404.
|
||||
But using API 'releases/latest', if no release has been created, the response will ALSO be 404.
|
||||
|
||||
This test ensures that we consider no update available when no releases are found.
|
||||
(And this is why we are using "releases" with a per_page=1 parameter, instead of "releases/latest")
|
||||
"""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(status_code=httpx.codes.OK, json=[])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url=GITHUB_API_URL
|
||||
) as client:
|
||||
notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
|
||||
update = await notifier.fetch_update()
|
||||
|
||||
assert update is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_considers_no_update_available_when_only_drafts_and_prereleases_are_found() -> (
|
||||
None
|
||||
):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=httpx.codes.OK,
|
||||
json=[
|
||||
{"tag_name": "v2.0.0-beta", "prerelease": True, "draft": False},
|
||||
{"tag_name": "v2.0.0", "prerelease": False, "draft": True},
|
||||
],
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url=GITHUB_API_URL
|
||||
) as client:
|
||||
notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
|
||||
update = await notifier.fetch_update()
|
||||
|
||||
assert update is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_picks_the_most_recently_published_non_prerelease_and_non_draft() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=httpx.codes.OK,
|
||||
json=[
|
||||
{
|
||||
"tag_name": "v2.0.0-beta",
|
||||
"prerelease": True,
|
||||
"draft": False,
|
||||
"published_at": "2025-10-25T112:00:00Z",
|
||||
},
|
||||
{
|
||||
"tag_name": "v2.0.0",
|
||||
"prerelease": False,
|
||||
"draft": True,
|
||||
"published_at": "2025-10-26T112:00:00Z",
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.12.455",
|
||||
"prerelease": False,
|
||||
"draft": False,
|
||||
"published_at": "2025-11-02T112:00:00Z",
|
||||
},
|
||||
{
|
||||
"tag_name": "1.12.400",
|
||||
"prerelease": False,
|
||||
"draft": False,
|
||||
"published_at": "2025-11-10T112:00:00Z",
|
||||
},
|
||||
{
|
||||
"tag_name": "1.12.300",
|
||||
"prerelease": False,
|
||||
"draft": False,
|
||||
"published_at": "2025-11-11T112:00:00Z",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url=GITHUB_API_URL
|
||||
) as client:
|
||||
notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
|
||||
update = await notifier.fetch_update()
|
||||
|
||||
assert update is not None
|
||||
assert update.latest_version == "1.12.300"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
[{"tag_name": "v2.0.0-beta", "prerelease": True, "draft": False}],
|
||||
[{"tag_name": "v2.0.0", "prerelease": False, "draft": True}],
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignores_draft_releases_and_prereleases(
|
||||
payload: dict[str, object],
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(status_code=httpx.codes.OK, json=payload)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url=GITHUB_API_URL
|
||||
) as client:
|
||||
notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
|
||||
update = await notifier.fetch_update()
|
||||
|
||||
assert update is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("handler", "expected_cause", "expected_custom_message"),
|
||||
[
|
||||
(
|
||||
lambda _: httpx.Response(status_code=httpx.codes.NOT_FOUND),
|
||||
VersionUpdateGatewayCause.NOT_FOUND,
|
||||
"Unable to fetch the GitHub releases. Did you export a GITHUB_TOKEN environment variable?",
|
||||
),
|
||||
(
|
||||
lambda _: httpx.Response(
|
||||
status_code=httpx.codes.FORBIDDEN,
|
||||
headers={"X-RateLimit-Remaining": "0"},
|
||||
),
|
||||
VersionUpdateGatewayCause.TOO_MANY_REQUESTS,
|
||||
None,
|
||||
),
|
||||
(
|
||||
lambda _: httpx.Response(status_code=httpx.codes.TOO_MANY_REQUESTS),
|
||||
VersionUpdateGatewayCause.TOO_MANY_REQUESTS,
|
||||
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, text="not json"),
|
||||
VersionUpdateGatewayCause.INVALID_RESPONSE,
|
||||
None,
|
||||
),
|
||||
(_raise_connect_timeout, VersionUpdateGatewayCause.REQUEST_FAILED, None),
|
||||
],
|
||||
ids=[
|
||||
"not_found",
|
||||
"rate_limit_header",
|
||||
"rate_limit_status",
|
||||
"forbidden",
|
||||
"error_response",
|
||||
"invalid_json",
|
||||
"request_error",
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieves_nothing_when_fetching_update_fails(
|
||||
handler: Handler,
|
||||
expected_cause: VersionUpdateGatewayCause,
|
||||
expected_custom_message: str | None,
|
||||
) -> None:
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url=GITHUB_API_URL
|
||||
) as client:
|
||||
notifier = GitHubVersionUpdateGateway("owner", "repo", client=client)
|
||||
with pytest.raises(VersionUpdateGatewayError) as excinfo:
|
||||
await notifier.fetch_update()
|
||||
|
||||
assert excinfo.value.cause == expected_cause
|
||||
if expected_custom_message is not None:
|
||||
assert str(excinfo.value) == expected_custom_message
|
||||
161
tests/update_notifier/test_ui_version_update_notification.py
Normal file
161
tests/update_notifier/test_ui_version_update_notification.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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 (
|
||||
FakeVersionUpdateGateway,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update_gateway import (
|
||||
VersionUpdate,
|
||||
VersionUpdateGatewayCause,
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
|
||||
|
||||
async def _wait_for_notification(
|
||||
app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
|
||||
) -> Notification:
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
|
||||
while loop.time() < deadline:
|
||||
notifications = list(app._notifications)
|
||||
if notifications:
|
||||
return notifications[-1]
|
||||
await pilot.pause(interval)
|
||||
|
||||
pytest.fail("Notification not displayed")
|
||||
|
||||
|
||||
async def _assert_no_notifications(
|
||||
app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
|
||||
) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
|
||||
while loop.time() < deadline:
|
||||
if app._notifications:
|
||||
pytest.fail("Notification unexpectedly displayed")
|
||||
await pilot.pause(interval)
|
||||
|
||||
assert not app._notifications
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vibe_config_with_update_checks_enabled() -> VibeConfig:
|
||||
return VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=True
|
||||
)
|
||||
|
||||
|
||||
class VibeAppFactory(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
notifier: FakeVersionUpdateGateway,
|
||||
config: VibeConfig | None = None,
|
||||
auto_approve: bool = False,
|
||||
current_version: str = "0.1.0",
|
||||
) -> VibeApp: ...
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeAppFactory:
|
||||
def _make_app(
|
||||
*,
|
||||
notifier: FakeVersionUpdateGateway,
|
||||
config: VibeConfig | None = None,
|
||||
auto_approve: bool = False,
|
||||
current_version: str = "0.1.0",
|
||||
) -> VibeApp:
|
||||
return VibeApp(
|
||||
config=config or vibe_config_with_update_checks_enabled,
|
||||
auto_approve=auto_approve,
|
||||
version_update_notifier=notifier,
|
||||
current_version=current_version,
|
||||
)
|
||||
|
||||
return _make_app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_displays_update_notification(make_vibe_app: VibeAppFactory) -> None:
|
||||
notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
|
||||
app = make_vibe_app(notifier=notifier)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
notification = await _wait_for_notification(app, pilot, timeout=0.3)
|
||||
|
||||
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'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_does_not_display_update_notification_when_not_available(
|
||||
make_vibe_app: VibeAppFactory,
|
||||
) -> None:
|
||||
notifier = FakeVersionUpdateGateway(update=None)
|
||||
app = make_vibe_app(notifier=notifier)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await _assert_no_notifications(app, pilot, timeout=0.3)
|
||||
assert notifier.fetch_update_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_displays_warning_toast_when_check_fails(
|
||||
make_vibe_app: VibeAppFactory,
|
||||
) -> None:
|
||||
notifier = FakeVersionUpdateGateway(
|
||||
error=VersionUpdateGatewayError(cause=VersionUpdateGatewayCause.FORBIDDEN)
|
||||
)
|
||||
app = make_vibe_app(notifier=notifier)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.3)
|
||||
notifications = list(app._notifications)
|
||||
|
||||
assert notifications
|
||||
warning = notifications[-1]
|
||||
assert warning.severity == "warning"
|
||||
assert "forbidden" in warning.message.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_does_not_invoke_gateway_nor_show_error_notification_when_update_checks_are_disabled(
|
||||
vibe_config_with_update_checks_enabled: VibeConfig, make_vibe_app: VibeAppFactory
|
||||
) -> None:
|
||||
config = vibe_config_with_update_checks_enabled
|
||||
config.enable_update_checks = False
|
||||
notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
|
||||
app = make_vibe_app(notifier=notifier, config=config)
|
||||
|
||||
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_not_invoke_gateway_nor_show_update_notification_when_update_checks_are_disabled(
|
||||
vibe_config_with_update_checks_enabled: VibeConfig, make_vibe_app: VibeAppFactory
|
||||
) -> None:
|
||||
config = vibe_config_with_update_checks_enabled
|
||||
config.enable_update_checks = False
|
||||
notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
|
||||
app = make_vibe_app(notifier=notifier, config=config)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await _assert_no_notifications(app, pilot, timeout=0.3)
|
||||
|
||||
assert notifier.fetch_update_calls == 0
|
||||
146
tests/update_notifier/test_version_update_use_case.py
Normal file
146
tests/update_notifier/test_version_update_use_case.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli.update_notifier.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 (
|
||||
VersionUpdate,
|
||||
VersionUpdateGatewayCause,
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieves_the_latest_version_update_when_available() -> None:
|
||||
latest_update = "1.0.3"
|
||||
version_update_notifier = FakeVersionUpdateGateway(
|
||||
update=VersionUpdate(latest_version=latest_update)
|
||||
)
|
||||
|
||||
update = await is_version_update_available(
|
||||
version_update_notifier, current_version="1.0.0"
|
||||
)
|
||||
|
||||
assert update is not None
|
||||
assert update.latest_version == latest_update
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieves_nothing_when_the_current_version_is_the_latest() -> None:
|
||||
current_version = "1.0.0"
|
||||
latest_version = "1.0.0"
|
||||
version_update_notifier = FakeVersionUpdateGateway(
|
||||
update=VersionUpdate(latest_version=latest_version)
|
||||
)
|
||||
|
||||
update = await is_version_update_available(
|
||||
version_update_notifier, current_version=current_version
|
||||
)
|
||||
|
||||
assert update is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieves_nothing_when_the_current_version_is_greater_than_the_latest() -> (
|
||||
None
|
||||
):
|
||||
current_version = "0.2.0"
|
||||
latest_version = "0.1.2"
|
||||
version_update_notifier = FakeVersionUpdateGateway(
|
||||
update=VersionUpdate(latest_version=latest_version)
|
||||
)
|
||||
|
||||
update = await is_version_update_available(
|
||||
version_update_notifier, current_version=current_version
|
||||
)
|
||||
|
||||
assert update is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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"
|
||||
)
|
||||
|
||||
assert update is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieves_nothing_when_latest_version_is_invalid() -> None:
|
||||
version_update_notifier = FakeVersionUpdateGateway(
|
||||
update=VersionUpdate(latest_version="invalid-version")
|
||||
)
|
||||
|
||||
update = await is_version_update_available(
|
||||
version_update_notifier, current_version="1.0.0"
|
||||
)
|
||||
|
||||
assert update is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replaces_hyphens_with_plus_signs_in_latest_version_to_conform_with_PEP_440() -> (
|
||||
None
|
||||
):
|
||||
version_update_notifier = FakeVersionUpdateGateway(
|
||||
# if we were not replacing hyphens with plus signs, this should fail for PEP 440
|
||||
update=VersionUpdate(latest_version="1.6.1-jetbrains")
|
||||
)
|
||||
|
||||
update = await is_version_update_available(
|
||||
version_update_notifier, current_version="1.0.0"
|
||||
)
|
||||
|
||||
assert update is not None
|
||||
assert update.latest_version == "1.6.1-jetbrains"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieves_nothing_when_current_version_is_invalid() -> None:
|
||||
version_update_notifier = FakeVersionUpdateGateway(
|
||||
update=VersionUpdate(latest_version="1.0.1")
|
||||
)
|
||||
|
||||
update = await is_version_update_available(
|
||||
version_update_notifier, current_version="invalid-version"
|
||||
)
|
||||
|
||||
assert update is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cause", "expected_message_substring"),
|
||||
[
|
||||
(VersionUpdateGatewayCause.TOO_MANY_REQUESTS, "Rate limit exceeded"),
|
||||
(VersionUpdateGatewayCause.INVALID_RESPONSE, "invalid response"),
|
||||
(
|
||||
VersionUpdateGatewayCause.NOT_FOUND,
|
||||
"Unable to fetch the releases. Please check your permissions.",
|
||||
),
|
||||
(VersionUpdateGatewayCause.ERROR_RESPONSE, "Unexpected response"),
|
||||
(VersionUpdateGatewayCause.REQUEST_FAILED, "Network error"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_version_update_error(
|
||||
cause: VersionUpdateGatewayCause, expected_message_substring: str
|
||||
) -> None:
|
||||
version_update_notifier = FakeVersionUpdateGateway(
|
||||
error=VersionUpdateGatewayError(cause=cause)
|
||||
)
|
||||
|
||||
with pytest.raises(VersionUpdateError) as excinfo:
|
||||
await is_version_update_available(
|
||||
version_update_notifier, current_version="1.0.0"
|
||||
)
|
||||
|
||||
assert expected_message_substring in str(excinfo.value)
|
||||
Loading…
Add table
Add a link
Reference in a new issue