Co-authored-by: Charles-Edouard Cady <charles.edouard.cady@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Sylvain Afchain <safchain@gmail.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-06-29 17:14:25 +02:00 committed by GitHub
parent e607ccbb00
commit ed5b7192e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1456 additions and 125 deletions

View file

@ -0,0 +1,26 @@
from __future__ import annotations
import pytest
from vibe.cli.commands import CommandRegistry
from vibe.cli.textual_ui.widgets.chat_input.input_kinds import (
Prompt,
SlashCommand,
classify,
)
def _classify(value: str) -> object:
return classify(value, commands=CommandRegistry(), expand_skill=lambda _value: None)
@pytest.mark.parametrize("alias", ["/exit", "exit", "quit", ":q", ":quit"])
def test_classify_treats_bare_exit_synonyms_as_slash_command(alias: str) -> None:
assert isinstance(_classify(alias), SlashCommand)
@pytest.mark.parametrize("value", ["exit the function early", "quit your job"])
def test_classify_keeps_bare_synonym_with_trailing_text_as_prompt(value: str) -> None:
result = _classify(value)
assert isinstance(result, Prompt)
assert result.text == value

View file

@ -0,0 +1,212 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.mcp_commands import parse_mcp_add_args, parse_mcp_subcommand
from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage
from vibe.core.config import MCPHttp, MCPOAuth, MCPStreamableHttp, VibeConfig
def _capture_mounted_widgets(
app: VibeApp, monkeypatch: pytest.MonkeyPatch
) -> list[object]:
mounted_widgets: list[object] = []
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
mounted_widgets.append(widget)
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
return mounted_widgets
def test_parse_mcp_add_args_accepts_url_and_options() -> None:
args = parse_mcp_add_args(
"https://mcp.example.com/mcp --name docs --scope read --scope write "
"--transport http --no-login"
)
assert args.url == "https://mcp.example.com/mcp"
assert args.name == "docs"
assert args.scopes == ["read", "write"]
assert args.transport == "http"
assert args.login is False
def test_parse_mcp_add_args_defaults_to_login() -> None:
args = parse_mcp_add_args("https://mcp.example.com/mcp")
assert args.transport == "streamable-http"
assert args.login is True
@pytest.mark.parametrize(
"raw_args",
[
"",
"https://mcp.example.com/mcp extra",
"https://mcp.example.com/mcp --unknown",
"https://mcp.example.com/mcp --name",
"https://mcp.example.com/mcp --name a --name b",
"https://mcp.example.com/mcp --scope",
"https://mcp.example.com/mcp --login",
"https://mcp.example.com/mcp --transport",
"https://mcp.example.com/mcp --transport sse",
"https://mcp.example.com/mcp --transport http --transport streamable-http",
"'unterminated",
],
)
def test_parse_mcp_add_args_rejects_invalid_args(raw_args: str) -> None:
with pytest.raises(ValueError):
parse_mcp_add_args(raw_args)
def test_parse_mcp_subcommand_recognizes_supported_subcommands() -> None:
parsed = parse_mcp_subcommand("add https://mcp.linear.app/mcp --no-login")
assert parsed is not None
assert parsed.name == "add"
assert parsed.args == "https://mcp.linear.app/mcp --no-login"
def test_parse_mcp_subcommand_ignores_unknown_subcommands() -> None:
assert parse_mcp_subcommand("tools linear") is None
@pytest.mark.asyncio
async def test_mcp_add_saves_oauth_server_and_prints_next_steps(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("https://mcp.linear.app/mcp --no-login")
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPStreamableHttp)
assert server.name == "linear"
assert isinstance(server.auth, MCPOAuth)
assert server.auth.scopes == []
assert any(
isinstance(widget, UserCommandMessage)
and "Added OAuth MCP server `linear`." in widget._content
and "/mcp login linear" in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_saves_name_and_scopes(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add(
"https://mcp.example.com/mcp --name docs --scope read --scope write --no-login"
)
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPStreamableHttp)
assert server.name == "docs"
assert isinstance(server.auth, MCPOAuth)
assert server.auth.scopes == ["read", "write"]
assert any(
isinstance(widget, UserCommandMessage)
and "Added OAuth MCP server `docs`." in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_saves_http_transport(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
_capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("https://mcp.example.com/mcp --transport http --no-login")
server = VibeConfig.load().mcp_servers[0]
assert isinstance(server, MCPHttp)
assert server.transport == "http"
assert isinstance(server.auth, MCPOAuth)
@pytest.mark.asyncio
async def test_mcp_add_delegates_to_login_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
login = AsyncMock()
monkeypatch.setattr(app, "_mcp_login", login)
await app._mcp_add("https://mcp.linear.app/mcp")
login.assert_awaited_once_with("linear")
assert any(
isinstance(widget, UserCommandMessage)
and "Starting OAuth login..." in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_no_login_skips_login(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
_capture_mounted_widgets(app, monkeypatch)
login = AsyncMock()
monkeypatch.setattr(app, "_mcp_login", login)
await app._mcp_add("https://mcp.linear.app/mcp --no-login")
login.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_add_mounts_parser_errors(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("https://mcp.example.com/mcp --unknown")
assert any(
isinstance(widget, ErrorMessage)
and widget._error == "Unknown /mcp add option: --unknown"
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_add_help_states_oauth_only(monkeypatch: pytest.MonkeyPatch) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
await app._mcp_add("--help")
assert any(
isinstance(widget, UserCommandMessage)
and "Usage: /mcp add <url>" in widget._content
and "--transport <http|streamable-http>" in widget._content
and "OAuth-only shortcut" in widget._content
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_mcp_subcommand_handler_recognizes_add(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(config=build_test_vibe_config())
mounted_widgets = _capture_mounted_widgets(app, monkeypatch)
handled = await app._maybe_handle_mcp_subcommand(
"add https://mcp.linear.app/mcp --no-login"
)
assert handled is True
assert any(
isinstance(widget, UserCommandMessage)
and "Added OAuth MCP server `linear`." in widget._content
for widget in mounted_widgets
)

View file

@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any
import pytest
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import SlashCommandMessage, UserMessage
@pytest.mark.parametrize("alias", ["/exit", "exit", "quit", ":q", ":quit"])
@pytest.mark.asyncio
async def test_exit_synonym_runs_exit_handler_and_is_not_sent_as_prompt(
alias: str, monkeypatch: pytest.MonkeyPatch
) -> None:
vibe_app = build_test_vibe_app()
async with vibe_app.run_test() as pilot:
await pilot.pause(0.1)
calls: list[str] = []
async def _record_exit(**_kwargs: Any) -> None:
calls.append(alias)
monkeypatch.setattr(vibe_app, "_exit_app", _record_exit)
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.post_message(ChatInputContainer.Submitted(alias))
await pilot.pause(0.2)
assert calls == [alias]
prompts = [
m
for m in vibe_app.query(UserMessage)
if not isinstance(m, SlashCommandMessage)
]
assert prompts == []

View file

@ -1,10 +1,13 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import signal
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from textual.app import WINDOWS
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.app import VibeApp, _run_app_with_cleanup
@ -249,3 +252,35 @@ async def test_run_app_with_cleanup_runs_cleanup_when_run_async_raises(
await _run_app_with_cleanup(app)
cleanup.assert_awaited_once()
def test_force_quit_delegates_to_private(app: VibeApp) -> None:
with patch.object(app, "_force_quit") as private:
app._force_quit()
private.assert_called_once_with()
@pytest.mark.skipif(WINDOWS, reason="SIGTERM handlers are not installed on Windows")
@pytest.mark.asyncio
async def test_run_app_with_cleanup_sigterm_triggers_force_quit(app: VibeApp) -> None:
captured: list[Callable[[], None]] = []
loop = asyncio.get_running_loop()
def capture_add(sig: int, callback: Callable[[], None], *args: object) -> None:
captured.append(callback)
async def fake_run_async() -> None:
captured[0]()
with (
patch.object(loop, "add_signal_handler", side_effect=capture_add),
patch.object(loop, "remove_signal_handler") as remove_handler,
patch.object(app, "run_async", side_effect=fake_run_async),
patch.object(app, "shutdown_cleanup", new_callable=AsyncMock),
patch.object(app, "_force_quit") as force_quit,
):
await _run_app_with_cleanup(app)
force_quit.assert_called_once_with()
remove_handler.assert_any_call(signal.SIGTERM)