v2.9.1 (#644)
Co-authored-by: Brice Carpentier <brice.carpentier@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
632ea8c032
commit
1fd7eea289
63 changed files with 3482 additions and 301 deletions
368
tests/cli/test_connector_auth_app.py
Normal file
368
tests/cli/test_connector_auth_app.py
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from textual.widgets import OptionList
|
||||
|
||||
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
|
||||
from vibe.cli.textual_ui.widgets.connector_auth_app import (
|
||||
ConnectorAuthApp,
|
||||
_AuthOptionId,
|
||||
)
|
||||
from vibe.core.tools.connectors.connector_registry import RemoteTool
|
||||
|
||||
|
||||
def _make_registry(*, with_disconnected: bool = True) -> FakeConnectorRegistry:
|
||||
connectors: dict[str, list[RemoteTool]] = {
|
||||
"gmail": [RemoteTool(name="search", description="Search emails")]
|
||||
}
|
||||
if with_disconnected:
|
||||
connectors["slack"] = []
|
||||
return FakeConnectorRegistry(connectors=connectors)
|
||||
|
||||
|
||||
def _make_app(
|
||||
connector_name: str = "slack", registry: FakeConnectorRegistry | None = None
|
||||
) -> ConnectorAuthApp:
|
||||
reg = registry or _make_registry()
|
||||
mgr = MagicMock()
|
||||
return ConnectorAuthApp(
|
||||
connector_name=connector_name, connector_registry=reg, tool_manager=mgr
|
||||
)
|
||||
|
||||
|
||||
class TestConnectorAuthAppInit:
|
||||
def test_widget_id(self) -> None:
|
||||
app = _make_app()
|
||||
assert app.id == "connectorauth-app"
|
||||
|
||||
def test_stores_connector_name(self) -> None:
|
||||
app = _make_app(connector_name="gmail")
|
||||
assert app._connector_name == "gmail"
|
||||
|
||||
def test_initial_state(self) -> None:
|
||||
app = _make_app()
|
||||
assert app._auth_url is None
|
||||
assert app._auth_url_visible is False
|
||||
assert app._status_message is None
|
||||
|
||||
|
||||
class TestAuthUrlFetched:
|
||||
def test_auth_url_available_shows_menu(self) -> None:
|
||||
app = _make_app()
|
||||
option_list = MagicMock()
|
||||
option_list.get_option_index.return_value = 2
|
||||
detail = MagicMock()
|
||||
help_widget = MagicMock()
|
||||
|
||||
def query(sel, *a, **kw): # type: ignore[no-untyped-def]
|
||||
if sel is OptionList:
|
||||
return option_list
|
||||
s = str(sel)
|
||||
if "detail" in s:
|
||||
return detail
|
||||
if "help" in s:
|
||||
return help_widget
|
||||
return MagicMock()
|
||||
|
||||
app.query_one = query # type: ignore[assignment]
|
||||
|
||||
app._on_auth_url_fetched("https://auth.example.com/oauth")
|
||||
|
||||
assert app._auth_url == "https://auth.example.com/oauth"
|
||||
assert option_list.clear_options.called
|
||||
assert option_list.add_option.call_count == 5
|
||||
option_ids = [
|
||||
call.args[0].id
|
||||
for call in option_list.add_option.call_args_list
|
||||
if hasattr(call.args[0], "id") and call.args[0].id
|
||||
]
|
||||
assert _AuthOptionId.OPEN in option_ids
|
||||
assert _AuthOptionId.COPY in option_ids
|
||||
assert _AuthOptionId.SHOW in option_ids
|
||||
|
||||
def test_auth_url_none_shows_no_auth_message(self) -> None:
|
||||
app = _make_app()
|
||||
option_list = MagicMock()
|
||||
detail = MagicMock()
|
||||
help_widget = MagicMock()
|
||||
|
||||
def query(sel, *a, **kw): # type: ignore[no-untyped-def]
|
||||
if sel is OptionList:
|
||||
return option_list
|
||||
s = str(sel)
|
||||
if "detail" in s:
|
||||
return detail
|
||||
if "help" in s:
|
||||
return help_widget
|
||||
return MagicMock()
|
||||
|
||||
app.query_one = query # type: ignore[assignment]
|
||||
|
||||
app._on_auth_url_fetched(None)
|
||||
|
||||
assert app._auth_url is None
|
||||
last_option = option_list.add_option.call_args_list[-1].args[0]
|
||||
assert "does not provide authentication" in str(last_option.prompt)
|
||||
|
||||
|
||||
class TestAuthActions:
|
||||
def test_open_browser_calls_webbrowser(self) -> None:
|
||||
app = _make_app()
|
||||
app._auth_url = "https://auth.example.com/oauth"
|
||||
app.query_one = MagicMock()
|
||||
|
||||
with patch("vibe.cli.textual_ui.widgets.connector_auth_app.webbrowser") as wb:
|
||||
app._open_browser()
|
||||
wb.open.assert_called_once_with("https://auth.example.com/oauth")
|
||||
|
||||
assert app._status_message == "Opened in browser."
|
||||
|
||||
def test_open_browser_noop_without_url(self) -> None:
|
||||
app = _make_app()
|
||||
app._auth_url = None
|
||||
with patch("vibe.cli.textual_ui.widgets.connector_auth_app.webbrowser") as wb:
|
||||
app._open_browser()
|
||||
wb.open.assert_not_called()
|
||||
|
||||
def test_copy_url_calls_clipboard(self) -> None:
|
||||
app = cast(Any, _make_app())
|
||||
app._auth_url = "https://auth.example.com/oauth"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
type(app), "app", new_callable=lambda: property(lambda s: MagicMock())
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.connector_auth_app.copy_text_to_clipboard"
|
||||
) as copy_fn,
|
||||
):
|
||||
app._copy_url()
|
||||
copy_fn.assert_called_once()
|
||||
assert copy_fn.call_args.args[1] == "https://auth.example.com/oauth"
|
||||
assert (
|
||||
copy_fn.call_args.kwargs["success_message"]
|
||||
== "Auth URL copied to clipboard"
|
||||
)
|
||||
|
||||
def test_copy_url_noop_without_url(self) -> None:
|
||||
app = _make_app()
|
||||
app._auth_url = None
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.widgets.connector_auth_app.copy_text_to_clipboard"
|
||||
) as copy_fn:
|
||||
app._copy_url()
|
||||
copy_fn.assert_not_called()
|
||||
|
||||
def test_toggle_url_shows_then_hides(self) -> None:
|
||||
app = cast(Any, _make_app())
|
||||
app._auth_url = "https://auth.example.com/oauth"
|
||||
detail = MagicMock()
|
||||
app.query_one = lambda sel, *a, **kw: detail
|
||||
|
||||
assert app._auth_url_visible is False
|
||||
|
||||
app._toggle_url()
|
||||
assert app._auth_url_visible is True
|
||||
text = detail.update.call_args.args[0]
|
||||
assert "https://auth.example.com/oauth" in text
|
||||
assert "Once authenticated" in text
|
||||
|
||||
app._toggle_url()
|
||||
assert app._auth_url_visible is False
|
||||
text = detail.update.call_args.args[0]
|
||||
assert "https://auth.example.com/oauth" not in text
|
||||
|
||||
def test_toggle_url_noop_without_url(self) -> None:
|
||||
app = _make_app()
|
||||
app._auth_url = None
|
||||
app._toggle_url()
|
||||
assert app._auth_url_visible is False
|
||||
|
||||
|
||||
class TestDetailText:
|
||||
def test_without_url_visible(self) -> None:
|
||||
app = cast(Any, _make_app())
|
||||
app._auth_url = "https://auth.example.com/oauth"
|
||||
app._auth_url_visible = False
|
||||
detail = MagicMock()
|
||||
app.query_one = lambda sel, *a, **kw: detail
|
||||
|
||||
app._update_detail_text()
|
||||
|
||||
text = detail.update.call_args.args[0]
|
||||
assert "Once authenticated, press R to refresh" in text
|
||||
assert "https://auth.example.com" not in text
|
||||
|
||||
def test_with_url_visible(self) -> None:
|
||||
app = cast(Any, _make_app())
|
||||
app._auth_url = "https://auth.example.com/oauth"
|
||||
app._auth_url_visible = True
|
||||
detail = MagicMock()
|
||||
app.query_one = lambda sel, *a, **kw: detail
|
||||
|
||||
app._update_detail_text()
|
||||
|
||||
text = detail.update.call_args.args[0]
|
||||
assert "https://auth.example.com/oauth" in text
|
||||
assert "Once authenticated, press R to refresh" in text
|
||||
# URL should come before the footer
|
||||
url_pos = text.index("https://auth.example.com/oauth")
|
||||
footer_pos = text.index("Once authenticated")
|
||||
assert url_pos < footer_pos
|
||||
|
||||
|
||||
class TestCloseAndRefresh:
|
||||
def test_action_close_posts_message(self) -> None:
|
||||
app = _make_app()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
app.action_close()
|
||||
|
||||
msg = app.post_message.call_args.args[0]
|
||||
assert isinstance(msg, ConnectorAuthApp.ConnectorAuthClosed)
|
||||
assert msg.refreshed is False
|
||||
|
||||
def test_on_connector_refreshed_with_tools_posts_closed(self) -> None:
|
||||
app = _make_app()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
app._on_connector_refreshed(3)
|
||||
|
||||
msg = app.post_message.call_args.args[0]
|
||||
assert isinstance(msg, ConnectorAuthApp.ConnectorAuthClosed)
|
||||
assert msg.refreshed is True
|
||||
assert msg.connector_name == "slack"
|
||||
assert "3 tools" in (app._status_message or "")
|
||||
|
||||
def test_on_connector_refreshed_without_tools_stays_on_page(self) -> None:
|
||||
app = _make_app()
|
||||
app.post_message = MagicMock()
|
||||
app.query_one = MagicMock()
|
||||
|
||||
app._on_connector_refreshed(0)
|
||||
|
||||
app.post_message.assert_not_called()
|
||||
assert app._status_message is not None
|
||||
assert "pending" in app._status_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_refresh_dispatches_worker(self) -> None:
|
||||
app = _make_app()
|
||||
app.run_worker = MagicMock()
|
||||
app.query_one = MagicMock()
|
||||
|
||||
await app.action_refresh()
|
||||
|
||||
assert app._status_message == "Refreshing connector..."
|
||||
app.run_worker.assert_called_once()
|
||||
_, kwargs = app.run_worker.call_args
|
||||
assert kwargs["group"] == "connector_refresh"
|
||||
|
||||
|
||||
class TestOptionSelection:
|
||||
def test_auth_open_dispatches(self) -> None:
|
||||
app = _make_app()
|
||||
app._auth_url = "https://auth.example.com"
|
||||
app.query_one = MagicMock()
|
||||
event = MagicMock()
|
||||
event.option.id = _AuthOptionId.OPEN
|
||||
|
||||
with patch("vibe.cli.textual_ui.widgets.connector_auth_app.webbrowser") as wb:
|
||||
app.on_option_list_option_selected(event)
|
||||
wb.open.assert_called_once()
|
||||
|
||||
def test_auth_copy_dispatches(self) -> None:
|
||||
app = cast(Any, _make_app())
|
||||
app._auth_url = "https://auth.example.com"
|
||||
event = MagicMock()
|
||||
event.option.id = _AuthOptionId.COPY
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
type(app), "app", new_callable=lambda: property(lambda s: MagicMock())
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.connector_auth_app.copy_text_to_clipboard"
|
||||
) as copy_fn,
|
||||
):
|
||||
app.on_option_list_option_selected(event)
|
||||
copy_fn.assert_called_once()
|
||||
|
||||
def test_auth_show_dispatches(self) -> None:
|
||||
app = cast(Any, _make_app())
|
||||
app._auth_url = "https://auth.example.com"
|
||||
app.query_one = MagicMock()
|
||||
event = MagicMock()
|
||||
event.option.id = _AuthOptionId.SHOW
|
||||
|
||||
app.on_option_list_option_selected(event)
|
||||
assert app._auth_url_visible is True
|
||||
|
||||
def test_unknown_option_is_noop(self) -> None:
|
||||
app = _make_app()
|
||||
event = MagicMock()
|
||||
event.option.id = "something:else"
|
||||
# Should not raise
|
||||
app.on_option_list_option_selected(event)
|
||||
|
||||
|
||||
class TestWorkerStateChanged:
|
||||
def test_auth_url_worker_dispatches(self) -> None:
|
||||
app = _make_app()
|
||||
app.query_one = MagicMock()
|
||||
worker = MagicMock()
|
||||
worker.group = "auth_url"
|
||||
worker.is_finished = True
|
||||
worker.result = "https://auth.example.com"
|
||||
event = MagicMock()
|
||||
event.worker = worker
|
||||
|
||||
app.on_worker_state_changed(event)
|
||||
|
||||
assert app._auth_url == "https://auth.example.com"
|
||||
|
||||
def test_connector_refresh_worker_with_tools_posts_closed(self) -> None:
|
||||
app = _make_app()
|
||||
app.post_message = MagicMock()
|
||||
worker = MagicMock()
|
||||
worker.group = "connector_refresh"
|
||||
worker.is_finished = True
|
||||
worker.result = 5
|
||||
event = MagicMock()
|
||||
event.worker = worker
|
||||
|
||||
app.on_worker_state_changed(event)
|
||||
|
||||
msg = app.post_message.call_args.args[0]
|
||||
assert isinstance(msg, ConnectorAuthApp.ConnectorAuthClosed)
|
||||
assert msg.refreshed is True
|
||||
|
||||
def test_connector_refresh_worker_without_tools_stays(self) -> None:
|
||||
app = _make_app()
|
||||
app.post_message = MagicMock()
|
||||
app.query_one = MagicMock()
|
||||
worker = MagicMock()
|
||||
worker.group = "connector_refresh"
|
||||
worker.is_finished = True
|
||||
worker.result = 0
|
||||
event = MagicMock()
|
||||
event.worker = worker
|
||||
|
||||
app.on_worker_state_changed(event)
|
||||
|
||||
app.post_message.assert_not_called()
|
||||
assert "pending" in (app._status_message or "")
|
||||
|
||||
def test_unrelated_worker_is_ignored(self) -> None:
|
||||
app = _make_app()
|
||||
worker = MagicMock()
|
||||
worker.group = "other"
|
||||
worker.is_finished = True
|
||||
event = MagicMock()
|
||||
event.worker = worker
|
||||
|
||||
# Should not raise
|
||||
app.on_worker_state_changed(event)
|
||||
|
|
@ -8,7 +8,10 @@ import pytest
|
|||
|
||||
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
|
||||
from vibe.cli.textual_ui.widgets.mcp_app import (
|
||||
_LIST_VIEW_HELP_AUTH,
|
||||
_LIST_VIEW_HELP_TOOLS,
|
||||
MCPApp,
|
||||
MCPSourceKind,
|
||||
_sort_connector_names_for_menu,
|
||||
collect_mcp_tool_index,
|
||||
)
|
||||
|
|
@ -258,3 +261,115 @@ class TestConnectorMenuOrdering:
|
|||
)
|
||||
|
||||
assert ordered == ["alpha", "Beta", "Zeta"]
|
||||
|
||||
|
||||
class TestHelpBarChanges:
|
||||
"""The help bar should show 'Enter Authenticate' for disconnected connectors."""
|
||||
|
||||
def _make_app_with_connectors(self) -> MCPApp:
|
||||
registry = FakeConnectorRegistry(
|
||||
connectors={
|
||||
"gmail": [RemoteTool(name="search", description="Search")],
|
||||
"slack": [],
|
||||
}
|
||||
)
|
||||
mgr = _make_tool_manager({})
|
||||
return MCPApp(
|
||||
mcp_servers=[MCPStdio(name="srv", transport="stdio", command="cmd")],
|
||||
tool_manager=mgr,
|
||||
connector_registry=registry,
|
||||
)
|
||||
|
||||
def test_help_shows_show_tools_for_server(self) -> None:
|
||||
app = self._make_app_with_connectors()
|
||||
option = MagicMock()
|
||||
option.id = "server:srv"
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
def test_help_shows_show_tools_for_connected_connector(self) -> None:
|
||||
app = self._make_app_with_connectors()
|
||||
option = MagicMock()
|
||||
option.id = "connector:gmail"
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
def test_help_shows_authenticate_for_disconnected_connector(self) -> None:
|
||||
app = self._make_app_with_connectors()
|
||||
option = MagicMock()
|
||||
option.id = "connector:slack"
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH
|
||||
|
||||
def test_help_defaults_to_tools_for_unknown_option(self) -> None:
|
||||
app = self._make_app_with_connectors()
|
||||
option = MagicMock()
|
||||
option.id = ""
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
def test_help_shows_tools_without_registry(self) -> None:
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[], tool_manager=mgr)
|
||||
option = MagicMock()
|
||||
option.id = "connector:slack"
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
|
||||
class TestConnectorAuthRequested:
|
||||
"""Clicking a disconnected connector posts ConnectorAuthRequested."""
|
||||
|
||||
def test_disconnected_connector_posts_auth_requested(self) -> None:
|
||||
registry = FakeConnectorRegistry(connectors={"slack": []})
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry)
|
||||
app.query_one = MagicMock()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
option_list = MagicMock()
|
||||
app._show_detail_view(
|
||||
"slack", option_list, app._index, kind=MCPSourceKind.CONNECTOR
|
||||
)
|
||||
|
||||
app.post_message.assert_called_once()
|
||||
msg = app.post_message.call_args.args[0]
|
||||
assert isinstance(msg, MCPApp.ConnectorAuthRequested)
|
||||
assert msg.connector_name == "slack"
|
||||
assert msg.connector_registry is registry
|
||||
assert msg.tool_manager is mgr
|
||||
|
||||
def test_connected_connector_with_no_indexed_tools_shows_message(self) -> None:
|
||||
registry = MagicMock()
|
||||
registry.get_connector_names.return_value = ["slack"]
|
||||
registry.is_connected.return_value = True
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry)
|
||||
app.query_one = MagicMock()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
option_list = MagicMock()
|
||||
app._show_detail_view(
|
||||
"slack", option_list, app._index, kind=MCPSourceKind.CONNECTOR
|
||||
)
|
||||
|
||||
app.post_message.assert_not_called()
|
||||
option_list.add_option.assert_called_once()
|
||||
assert "No tools discovered" in str(
|
||||
option_list.add_option.call_args.args[0].prompt
|
||||
)
|
||||
|
||||
def test_server_with_no_tools_shows_message(self) -> None:
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(
|
||||
mcp_servers=[MCPStdio(name="srv", transport="stdio", command="cmd")],
|
||||
tool_manager=mgr,
|
||||
)
|
||||
app.query_one = MagicMock()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
option_list = MagicMock()
|
||||
app._show_detail_view("srv", option_list, app._index, kind=MCPSourceKind.SERVER)
|
||||
|
||||
# Should NOT post ConnectorAuthRequested for servers
|
||||
app.post_message.assert_not_called()
|
||||
# Should add a "no tools" option instead
|
||||
option_list.add_option.assert_called_once()
|
||||
assert "No tools discovered" in str(
|
||||
option_list.add_option.call_args.args[0].prompt
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue