v2.18.1 (#856)
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:
parent
e607ccbb00
commit
ed5b7192e6
33 changed files with 1456 additions and 125 deletions
26
tests/cli/test_input_kinds.py
Normal file
26
tests/cli/test_input_kinds.py
Normal 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
|
||||
212
tests/cli/test_mcp_add_command.py
Normal file
212
tests/cli/test_mcp_add_command.py
Normal 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
|
||||
)
|
||||
38
tests/cli/test_ui_exit_command.py
Normal file
38
tests/cli/test_ui_exit_command.py
Normal 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 == []
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue