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:
Quentin Torroba 2025-12-09 13:13:22 +01:00 committed by Quentin Torroba
commit fa15fc977b
200 changed files with 30484 additions and 0 deletions

View 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

View 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

View 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)