v2.18.0 (#843)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Cyprien <courtot.c@gmail.com> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@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: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: josephine-delas <57808586+josephine-delas@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
725d3a56ce
commit
e607ccbb00
242 changed files with 7372 additions and 1974 deletions
|
|
@ -183,7 +183,7 @@ def test_resolve_api_key_for_plan_falls_back_to_keyring(
|
|||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
keyring_api_key = "keyring_mistral_api_key"
|
||||
monkeypatch.setattr(
|
||||
"vibe.core.config._settings.keyring.get_password",
|
||||
"vibe.core.utils.keyring.keyring.get_password",
|
||||
lambda service, username: keyring_api_key,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ def _fail(msg: str) -> NoReturn:
|
|||
def _isolated_env(vibe_home: Path) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["VIBE_HOME"] = str(vibe_home)
|
||||
env["VIBE_TEST_DISABLE_KEYRING"] = "1"
|
||||
env["TERM"] = env.get("TERM") or "xterm-256color"
|
||||
env.pop("MISTRAL_API_KEY", None)
|
||||
env.pop("VIBE_MISTRAL_API_KEY", None)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ from __future__ import annotations
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from textual.widgets import TextArea
|
||||
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -51,3 +53,20 @@ async def test_mouse_up_respects_autocopy_config_disabled() -> None:
|
|||
async with app.run_test() as pilot:
|
||||
await pilot.click()
|
||||
mock_copy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_selection_copies_text_area_selection() -> None:
|
||||
app = build_test_vibe_app()
|
||||
|
||||
with patch("vibe.cli.clipboard.try_copy_text_to_clipboard") as mock_copy:
|
||||
mock_copy.return_value = True
|
||||
async with app.run_test():
|
||||
text_area = app.query_one(TextArea)
|
||||
text_area.load_text("hello world")
|
||||
text_area.select_all()
|
||||
|
||||
result = copy_selection_to_clipboard(app, show_toast=False)
|
||||
|
||||
assert result == "hello world"
|
||||
mock_copy.assert_called_once_with("hello world")
|
||||
|
|
|
|||
171
tests/cli/test_lazy_audio_managers.py
Normal file
171
tests/cli/test_lazy_audio_managers.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import (
|
||||
build_test_agent_loop,
|
||||
build_test_vibe_app,
|
||||
build_test_vibe_config,
|
||||
)
|
||||
from tests.stubs.fake_voice_manager import FakeVoiceManager
|
||||
from vibe.cli.narrator_manager.narrator_manager_port import (
|
||||
NarratorManagerListener,
|
||||
NarratorState,
|
||||
)
|
||||
from vibe.cli.textual_ui.lazy_audio_managers import (
|
||||
LazyNarratorManager,
|
||||
LazyVoiceManager,
|
||||
)
|
||||
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.types import BaseEvent
|
||||
|
||||
|
||||
class FakeNarratorManager:
|
||||
state = NarratorState.IDLE
|
||||
is_playing = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.listeners: list[NarratorManagerListener] = []
|
||||
self.synced = False
|
||||
|
||||
def on_turn_start(self, user_message: str) -> None:
|
||||
pass
|
||||
|
||||
def on_turn_event(self, event: BaseEvent) -> None:
|
||||
pass
|
||||
|
||||
def on_turn_error(self, message: str) -> None:
|
||||
pass
|
||||
|
||||
def on_turn_cancel(self) -> None:
|
||||
pass
|
||||
|
||||
def on_turn_end(self) -> None:
|
||||
pass
|
||||
|
||||
def cancel(self) -> None:
|
||||
pass
|
||||
|
||||
def sync(self) -> None:
|
||||
self.synced = True
|
||||
|
||||
def add_listener(self, listener: NarratorManagerListener) -> None:
|
||||
if listener not in self.listeners:
|
||||
self.listeners.append(listener)
|
||||
|
||||
def remove_listener(self, listener: NarratorManagerListener) -> None:
|
||||
try:
|
||||
self.listeners.remove(listener)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_importing_tui_app_does_not_import_optional_audio_modules() -> None:
|
||||
code = """
|
||||
import sys
|
||||
import vibe.cli.textual_ui.app
|
||||
|
||||
blocked = [
|
||||
"sounddevice",
|
||||
"vibe.cli.narrator_manager.narrator_manager",
|
||||
"vibe.cli.voice_manager.voice_manager",
|
||||
"vibe.core.audio_player.audio_player",
|
||||
"vibe.core.audio_recorder.audio_recorder",
|
||||
"vibe.core.transcribe.factory",
|
||||
"vibe.core.tts.factory",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected optional modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], check=False, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_default_tui_app_does_not_materialize_disabled_optional_managers() -> None:
|
||||
config = build_test_vibe_config(voice_mode_enabled=False, narrator_enabled=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.lazy_audio_managers._create_real_voice_manager",
|
||||
side_effect=AssertionError("voice should stay lazy"),
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.lazy_audio_managers._create_real_narrator_manager",
|
||||
side_effect=AssertionError("narrator should stay lazy"),
|
||||
),
|
||||
):
|
||||
app = build_test_vibe_app(config=config, voice_manager=None)
|
||||
|
||||
assert app._voice_manager.is_enabled is False
|
||||
assert app._voice_manager.transcribe_state == TranscribeState.IDLE
|
||||
assert app._narrator_manager.state == NarratorState.IDLE
|
||||
|
||||
|
||||
def test_lazy_voice_manager_materializes_when_used() -> None:
|
||||
config = build_test_vibe_config(voice_mode_enabled=False)
|
||||
factory = MagicMock(return_value=FakeVoiceManager(is_voice_ready=True))
|
||||
manager = LazyVoiceManager(lambda: config, factory)
|
||||
|
||||
assert manager.is_enabled is False
|
||||
assert manager.transcribe_state == TranscribeState.IDLE
|
||||
factory.assert_not_called()
|
||||
|
||||
manager.start_recording()
|
||||
|
||||
factory.assert_called_once()
|
||||
assert manager.transcribe_state == TranscribeState.RECORDING
|
||||
|
||||
|
||||
def test_lazy_narrator_manager_materializes_when_enabled_at_startup() -> None:
|
||||
config = build_test_vibe_config(narrator_enabled=True)
|
||||
narrator = FakeNarratorManager()
|
||||
factory = MagicMock(return_value=narrator)
|
||||
|
||||
manager = LazyNarratorManager(lambda: config, factory)
|
||||
|
||||
factory.assert_called_once()
|
||||
assert manager.state == NarratorState.IDLE
|
||||
|
||||
|
||||
def test_lazy_narrator_manager_sync_materializes_after_enable() -> None:
|
||||
config = build_test_vibe_config(narrator_enabled=False)
|
||||
narrator = FakeNarratorManager()
|
||||
factory = MagicMock(return_value=narrator)
|
||||
manager = LazyNarratorManager(lambda: config, factory)
|
||||
|
||||
config.narrator_enabled = True
|
||||
manager.sync()
|
||||
|
||||
factory.assert_called_once()
|
||||
assert narrator.synced is False
|
||||
|
||||
|
||||
def test_tui_config_refresh_syncs_lazy_narrator(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
initial_config = build_test_vibe_config(narrator_enabled=False)
|
||||
refreshed_config = build_test_vibe_config(narrator_enabled=True)
|
||||
agent_loop = build_test_agent_loop(config=initial_config)
|
||||
narrator = FakeNarratorManager()
|
||||
factory = MagicMock(return_value=narrator)
|
||||
narrator_manager = LazyNarratorManager(lambda: agent_loop.config, factory)
|
||||
app = build_test_vibe_app(agent_loop=agent_loop, narrator_manager=narrator_manager)
|
||||
monkeypatch.setattr(VibeConfig, "load", staticmethod(lambda: refreshed_config))
|
||||
|
||||
app._refresh_config_from_disk()
|
||||
|
||||
factory.assert_called_once()
|
||||
assert narrator.synced is False
|
||||
|
|
@ -8,6 +8,7 @@ import pytest
|
|||
from textual.widgets.option_list import Option
|
||||
|
||||
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
|
||||
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
|
||||
from vibe.cli.textual_ui.widgets.mcp_app import (
|
||||
_LIST_VIEW_HELP_AUTH,
|
||||
_LIST_VIEW_HELP_TOOLS,
|
||||
|
|
@ -16,7 +17,7 @@ from vibe.cli.textual_ui.widgets.mcp_app import (
|
|||
_sort_connector_names_for_menu,
|
||||
collect_mcp_tool_index,
|
||||
)
|
||||
from vibe.core.config import MCPStdio
|
||||
from vibe.core.config import MCPHttp, MCPOAuth, MCPStdio
|
||||
from vibe.core.tools.base import InvokeContext
|
||||
from vibe.core.tools.connectors.connector_registry import (
|
||||
ConnectorAuthAction,
|
||||
|
|
@ -62,6 +63,16 @@ def _make_tool_manager(
|
|||
return mgr
|
||||
|
||||
|
||||
def _make_oauth_server(*, name: str = "oauth", disabled: bool = False) -> MCPHttp:
|
||||
return MCPHttp(
|
||||
name=name,
|
||||
transport="http",
|
||||
url="http://mcp.example.test",
|
||||
auth=MCPOAuth(type="oauth", scopes=[]),
|
||||
disabled=disabled,
|
||||
)
|
||||
|
||||
|
||||
class TestCollectMcpToolIndex:
|
||||
def test_non_mcp_tools_are_excluded(self) -> None:
|
||||
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
|
||||
|
|
@ -343,6 +354,26 @@ class TestHelpBarChanges:
|
|||
option = Option("", id="connector:slack")
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH
|
||||
|
||||
def test_help_shows_authenticate_for_oauth_server(self) -> None:
|
||||
server = _make_oauth_server()
|
||||
registry = FakeMCPRegistry()
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry)
|
||||
|
||||
option = Option("", id="server:oauth")
|
||||
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH
|
||||
|
||||
def test_help_shows_tools_for_disabled_oauth_server(self) -> None:
|
||||
server = _make_oauth_server(disabled=True)
|
||||
registry = FakeMCPRegistry()
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry)
|
||||
|
||||
option = Option("", id="server:oauth")
|
||||
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
def test_help_shows_tools_for_degraded_connector(self) -> None:
|
||||
registry = FakeConnectorRegistry(connectors={"slack": []})
|
||||
mgr = _make_tool_manager({})
|
||||
|
|
@ -443,3 +474,84 @@ class TestConnectorAuthRequested:
|
|||
assert "No tools discovered" in str(
|
||||
option_list.add_option.call_args.args[0].prompt
|
||||
)
|
||||
|
||||
def test_oauth_server_with_no_tools_posts_auth_requested(self) -> None:
|
||||
server = _make_oauth_server()
|
||||
registry = FakeMCPRegistry()
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry)
|
||||
app.query_one = MagicMock()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
option_list = MagicMock()
|
||||
app._show_detail_view(
|
||||
"oauth", option_list, app._index, kind=MCPSourceKind.SERVER
|
||||
)
|
||||
|
||||
app.post_message.assert_called_once()
|
||||
msg = app.post_message.call_args.args[0]
|
||||
assert isinstance(msg, MCPApp.MCPOAuthRequested)
|
||||
assert msg.server_name == "oauth"
|
||||
assert msg.mcp_registry is registry
|
||||
option_list.add_option.assert_not_called()
|
||||
|
||||
def test_oauth_server_with_stale_tools_posts_auth_requested(self) -> None:
|
||||
server = _make_oauth_server()
|
||||
registry = FakeMCPRegistry()
|
||||
stale_tool = _make_tool_cls(
|
||||
is_mcp=True, server_name="oauth", remote_name="stale"
|
||||
)
|
||||
mgr = _make_tool_manager({"oauth_stale": stale_tool})
|
||||
app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry)
|
||||
app.query_one = MagicMock()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
option_list = MagicMock()
|
||||
app._show_detail_view(
|
||||
"oauth", option_list, app._index, kind=MCPSourceKind.SERVER
|
||||
)
|
||||
|
||||
app.post_message.assert_called_once()
|
||||
assert isinstance(app.post_message.call_args.args[0], MCPApp.MCPOAuthRequested)
|
||||
option_list.add_option.assert_not_called()
|
||||
|
||||
|
||||
class TestServerStatusLabels:
|
||||
def test_servers_show_enabled_disabled_and_needs_auth(self) -> None:
|
||||
servers = [
|
||||
MCPStdio(name="filesystem", transport="stdio", command="npx"),
|
||||
MCPHttp(name="search", transport="http", url="http://mcp.example.test"),
|
||||
_make_oauth_server(name="oauth"),
|
||||
_make_oauth_server(name="disabled-oauth", disabled=True),
|
||||
]
|
||||
registry = FakeMCPRegistry()
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=servers, tool_manager=mgr, mcp_registry=registry)
|
||||
option_list = MagicMock()
|
||||
|
||||
app._list_mcp_servers(option_list, app._index)
|
||||
|
||||
prompts = [
|
||||
str(call.args[0].prompt) for call in option_list.add_option.call_args_list
|
||||
]
|
||||
assert any("filesystem" in prompt and "enabled" in prompt for prompt in prompts)
|
||||
assert any("search" in prompt and "enabled" in prompt for prompt in prompts)
|
||||
assert any("oauth" in prompt and "needs auth" in prompt for prompt in prompts)
|
||||
assert any(
|
||||
"disabled-oauth" in prompt and "disabled" in prompt for prompt in prompts
|
||||
)
|
||||
|
||||
def test_oauth_server_with_cached_tools_shows_connected(self) -> None:
|
||||
server = _make_oauth_server()
|
||||
registry = FakeMCPRegistry()
|
||||
registry.set_tools([server], {})
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[server], tool_manager=mgr, mcp_registry=registry)
|
||||
option_list = MagicMock()
|
||||
|
||||
app._list_mcp_servers(option_list, app._index)
|
||||
|
||||
prompts = [
|
||||
str(call.args[0].prompt) for call in option_list.add_option.call_args_list
|
||||
]
|
||||
assert any("oauth" in prompt and "connected" in prompt for prompt in prompts)
|
||||
|
|
|
|||
172
tests/cli/test_mcp_oauth_app.py
Normal file
172
tests/cli/test_mcp_oauth_app.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from textual.widgets import OptionList
|
||||
|
||||
from vibe.cli.textual_ui.widgets.mcp_oauth_app import (
|
||||
MCPOAuthApp,
|
||||
_LoginResult,
|
||||
_OAuthOptionId,
|
||||
)
|
||||
|
||||
|
||||
class FakeLoginRegistry:
|
||||
def __init__(self, *, error: Exception | None = None) -> None:
|
||||
self.error = error
|
||||
self.login_calls: list[str] = []
|
||||
|
||||
async def login(
|
||||
self, alias: str, *, on_url: Callable[[str], Awaitable[None]]
|
||||
) -> None:
|
||||
self.login_calls.append(alias)
|
||||
await on_url("https://auth.example.com/oauth")
|
||||
if self.error:
|
||||
raise self.error
|
||||
|
||||
|
||||
def _make_app(registry: FakeLoginRegistry | None = None) -> MCPOAuthApp:
|
||||
return MCPOAuthApp(
|
||||
server_name="oauth", mcp_registry=cast(Any, registry or FakeLoginRegistry())
|
||||
)
|
||||
|
||||
|
||||
def _wire_query(app: MCPOAuthApp) -> tuple[MagicMock, MagicMock, MagicMock]:
|
||||
option_list = MagicMock()
|
||||
option_list.get_option_index.return_value = 2
|
||||
detail = MagicMock()
|
||||
help_widget = MagicMock()
|
||||
|
||||
def query(sel: object, *a: object, **kw: object) -> MagicMock:
|
||||
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 = cast(Any, query)
|
||||
return option_list, detail, help_widget
|
||||
|
||||
|
||||
class TestMCPOAuthApp:
|
||||
def test_widget_id(self) -> None:
|
||||
app = _make_app()
|
||||
assert app.id == "mcpoauth-app"
|
||||
|
||||
def test_action_close_posts_cancelled_message(self) -> None:
|
||||
app = _make_app()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
app.action_close()
|
||||
|
||||
msg = app.post_message.call_args.args[0]
|
||||
assert isinstance(msg, MCPOAuthApp.MCPOAuthClosed)
|
||||
assert msg.refreshed is False
|
||||
assert msg.server_name == ""
|
||||
|
||||
def test_auth_url_available_shows_menu(self) -> None:
|
||||
app = _make_app()
|
||||
option_list, _detail, _help = _wire_query(app)
|
||||
|
||||
app._on_auth_url_available("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 _OAuthOptionId.OPEN in option_ids
|
||||
assert _OAuthOptionId.COPY in option_ids
|
||||
assert _OAuthOptionId.SHOW in option_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_login_starts_registry_login(self) -> None:
|
||||
registry = FakeLoginRegistry()
|
||||
app = _make_app(registry)
|
||||
_wire_query(app)
|
||||
|
||||
result = await app._run_login()
|
||||
|
||||
assert result == _LoginResult(authenticated=True)
|
||||
assert registry.login_calls == ["oauth"]
|
||||
assert app._auth_url == "https://auth.example.com/oauth"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_login_returns_error(self) -> None:
|
||||
registry = FakeLoginRegistry(error=ValueError("bad auth"))
|
||||
app = _make_app(registry)
|
||||
_wire_query(app)
|
||||
|
||||
result = await app._run_login()
|
||||
|
||||
assert result == _LoginResult(authenticated=False, error="bad auth")
|
||||
|
||||
def test_worker_success_posts_closed(self) -> None:
|
||||
app = _make_app()
|
||||
app.post_message = MagicMock()
|
||||
worker = MagicMock()
|
||||
worker.group = "mcp_oauth_login"
|
||||
worker.is_finished = True
|
||||
worker.result = _LoginResult(authenticated=True)
|
||||
event = MagicMock()
|
||||
event.worker = worker
|
||||
|
||||
app.on_worker_state_changed(event)
|
||||
|
||||
msg = app.post_message.call_args.args[0]
|
||||
assert isinstance(msg, MCPOAuthApp.MCPOAuthClosed)
|
||||
assert msg.refreshed is True
|
||||
assert msg.server_name == "oauth"
|
||||
|
||||
def test_worker_failure_shows_retry_message(self) -> None:
|
||||
app = _make_app()
|
||||
_wire_query(app)
|
||||
app.post_message = MagicMock()
|
||||
worker = MagicMock()
|
||||
worker.group = "mcp_oauth_login"
|
||||
worker.is_finished = True
|
||||
worker.result = _LoginResult(authenticated=False, error="bad auth")
|
||||
event = MagicMock()
|
||||
event.worker = worker
|
||||
|
||||
app.on_worker_state_changed(event)
|
||||
|
||||
app.post_message.assert_not_called()
|
||||
assert app._status_message == "bad auth"
|
||||
|
||||
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.mcp_oauth_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_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.mcp_oauth_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"
|
||||
|
|
@ -12,6 +12,7 @@ from tests.constants import OPENAI_BASE_URL
|
|||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.messages import ErrorMessage
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
|
@ -73,6 +74,23 @@ async def test_teleport_command_visible_for_paid_chat_users() -> None:
|
|||
assert "&" in input_widget.mode_characters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_resolution_updates_subscription_banner() -> None:
|
||||
app = build_test_vibe_app(
|
||||
config=_vibe_code_enabled_config(),
|
||||
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False),
|
||||
)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda: (
|
||||
"[Subscription] Pro"
|
||||
in str(app.query_one("#banner-user-plan", NoMarkupStatic).content)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_command_without_history_sends_early_failure_telemetry(
|
||||
telemetry_events: list[dict[str, Any]],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from textual.geometry import Offset
|
||||
from textual.selection import Selection
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer, ChatTextArea
|
||||
|
|
@ -62,3 +64,46 @@ async def test_shift_backspace_resets_mode_when_empty() -> None:
|
|||
await pilot.pause(0.1)
|
||||
|
||||
assert text_area.input_mode == ">"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_cleared_when_focus_leaves_text_area() -> None:
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area.load_text("hello world")
|
||||
text_area.select_all()
|
||||
await pilot.pause(0.1)
|
||||
assert text_area.selected_text == "hello world"
|
||||
|
||||
app.query_one("#chat").focus()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert text_area.selected_text == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blur_does_not_clear_other_widget_selection() -> None:
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.load_text("hello world")
|
||||
text_area.select_all()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
chat = app.query_one("#chat")
|
||||
sentinel = {chat: Selection(Offset(0, 0), Offset(0, 1))}
|
||||
app.screen.selections = sentinel
|
||||
text_area.screen.set_focus(chat)
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert text_area.selected_text == ""
|
||||
assert app.screen.selections == sentinel
|
||||
|
|
|
|||
35
tests/cli/textual_ui/test_context_progress.py
Normal file
35
tests/cli/textual_ui/test_context_progress.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
|
||||
|
||||
|
||||
def test_context_progress_shows_percentage_when_empty() -> None:
|
||||
widget = ContextProgress()
|
||||
|
||||
widget.watch_tokens(TokenState(max_tokens=200_000, current_tokens=0))
|
||||
|
||||
assert str(widget.render()) == "0/200k tokens (0%)"
|
||||
|
||||
|
||||
def test_context_progress_uses_compact_integer_format_for_used_tokens() -> None:
|
||||
widget = ContextProgress()
|
||||
|
||||
widget.watch_tokens(TokenState(max_tokens=200_000, current_tokens=12_500))
|
||||
|
||||
assert str(widget.render()) == "12k/200k tokens (6%)"
|
||||
|
||||
|
||||
def test_context_progress_uses_compact_integer_k_format() -> None:
|
||||
widget = ContextProgress()
|
||||
|
||||
widget.watch_tokens(TokenState(max_tokens=568_000, current_tokens=170_000))
|
||||
|
||||
assert str(widget.render()) == "170k/568k tokens (30%)"
|
||||
|
||||
|
||||
def test_context_progress_uses_compact_integer_m_format() -> None:
|
||||
widget = ContextProgress()
|
||||
|
||||
widget.watch_tokens(TokenState(max_tokens=40_000_000, current_tokens=35_900_000))
|
||||
|
||||
assert str(widget.render()) == "35.9M/40.0M tokens (90%)"
|
||||
162
tests/cli/textual_ui/test_lazy_startup_imports.py
Normal file
162
tests/cli/textual_ui/test_lazy_startup_imports.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def test_importing_tui_app_does_not_import_deferred_startup_modules() -> None:
|
||||
code = """
|
||||
import sys
|
||||
import vibe.cli.textual_ui.app
|
||||
|
||||
blocked = [
|
||||
"vibe.cli.textual_ui.widgets.connector_auth_app",
|
||||
"vibe.cli.textual_ui.widgets.mcp_app",
|
||||
"vibe.core.agent_loop",
|
||||
"vibe.core.tools.connectors.connector_registry",
|
||||
"vibe.core.tools.mcp.tools",
|
||||
"mcp",
|
||||
"git",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected startup modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], check=False, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_importing_agent_loop_does_not_import_remote_tool_modules() -> None:
|
||||
code = """
|
||||
import sys
|
||||
import vibe.core.agent_loop
|
||||
|
||||
blocked = [
|
||||
"vibe.core.tools.connectors.connector_registry",
|
||||
"vibe.core.tools.mcp.tools",
|
||||
"vibe.core.teleport.git",
|
||||
"vibe.core.teleport.teleport",
|
||||
"mcp",
|
||||
"git",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected agent loop modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], check=False, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_importing_connector_registry_does_not_import_mcp_runtime() -> None:
|
||||
code = """
|
||||
import sys
|
||||
import vibe.core.tools.connectors.connector_registry
|
||||
|
||||
blocked = [
|
||||
"vibe.core.tools.mcp.tools",
|
||||
"mcp",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected connector registry modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], check=False, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_importing_mcp_app_does_not_import_mcp_runtime() -> None:
|
||||
code = """
|
||||
import sys
|
||||
import vibe.cli.textual_ui.widgets.mcp_app
|
||||
|
||||
blocked = [
|
||||
"vibe.core.tools.mcp.tools",
|
||||
"mcp",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected mcp app modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code], check=False, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_constructing_deferred_agent_loop_does_not_import_mcp_package(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
code = """
|
||||
import sys
|
||||
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.config.harness_files import (
|
||||
init_harness_files_manager,
|
||||
reset_harness_files_manager,
|
||||
)
|
||||
|
||||
|
||||
class Backend:
|
||||
async def complete(self, **kwargs):
|
||||
raise AssertionError
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return None
|
||||
|
||||
|
||||
init_harness_files_manager("user", "project")
|
||||
try:
|
||||
config = VibeConfig(
|
||||
enable_connectors=False,
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
)
|
||||
loop = AgentLoop(
|
||||
config=config,
|
||||
backend=Backend(),
|
||||
defer_heavy_init=True,
|
||||
headless=True,
|
||||
)
|
||||
if loop._deferred_init_thread is not None:
|
||||
loop._deferred_init_thread.join()
|
||||
finally:
|
||||
reset_harness_files_manager()
|
||||
|
||||
blocked = [
|
||||
"vibe.core.tools.mcp.tools",
|
||||
"mcp",
|
||||
]
|
||||
loaded = [name for name in blocked if name in sys.modules]
|
||||
if loaded:
|
||||
raise SystemExit(f"unexpected deferred agent loop modules loaded: {loaded}")
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
**os.environ,
|
||||
"VIBE_HOME": str(tmp_path),
|
||||
"VIBE_TEST_DISABLE_KEYRING": "1",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
500
tests/cli/textual_ui/test_paste_image.py
Normal file
500
tests/cli/textual_ui/test_paste_image.py
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from textual import events
|
||||
from textual.app import App, ComposeResult
|
||||
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from vibe.cli.commands import CommandRegistry
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.chat_input import paste_image
|
||||
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.chat_input.paste_image import (
|
||||
_read_macos,
|
||||
_read_macos_class,
|
||||
handle_clipboard_image_paste,
|
||||
read_clipboard_image,
|
||||
write_clipboard_image,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
_PNG_HEADER = b"\x89PNG\r\n\x1a\n"
|
||||
_FAKE_PNG = _PNG_HEADER + b"fake-image-payload"
|
||||
|
||||
|
||||
def _build_vision_app(*, supports_images: bool = True) -> VibeApp:
|
||||
config = build_test_vibe_config(
|
||||
active_model="devstral-latest",
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="mistral-vibe-cli-latest",
|
||||
provider="mistral",
|
||||
alias="devstral-latest",
|
||||
supports_images=supports_images,
|
||||
)
|
||||
],
|
||||
providers=[
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
],
|
||||
)
|
||||
return build_test_vibe_app(config=config)
|
||||
|
||||
|
||||
def _completed(stdout: bytes = b"", returncode: int = 0) -> subprocess.CompletedProcess:
|
||||
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _force_supported_platform(monkeypatch) -> None:
|
||||
monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True)
|
||||
|
||||
|
||||
async def _press_ctrl_v_under_platform(monkeypatch, system: str) -> tuple[bool, list]:
|
||||
# The ctrl+v binding is registered at ChatTextArea class-definition time only
|
||||
# on macOS, so the result of pressing it depends on the OS. Reload the module
|
||||
# under a forced platform.system() to get a deterministically-defined class,
|
||||
# then exercise it in a minimal app (the full app shares binding maps across
|
||||
# instances via a shallow copy, which makes a host-dependent setup flaky).
|
||||
monkeypatch.setattr(platform, "system", lambda: system)
|
||||
text_area_module = importlib.import_module(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.text_area"
|
||||
)
|
||||
importlib.reload(text_area_module)
|
||||
try:
|
||||
chat_text_area = text_area_module.ChatTextArea
|
||||
has_binding = any(b.key == "ctrl+v" for b in chat_text_area.BINDINGS)
|
||||
|
||||
class _MiniApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield chat_text_area(command_registry=CommandRegistry())
|
||||
|
||||
posted: list = []
|
||||
async with _MiniApp().run_test() as pilot:
|
||||
text_area = pilot.app.query_one(chat_text_area)
|
||||
text_area.focus()
|
||||
await pilot.pause()
|
||||
original_post = text_area.post_message
|
||||
|
||||
def wrapped(message):
|
||||
if isinstance(message, chat_text_area.ClipboardImagePasted):
|
||||
posted.append(message)
|
||||
return original_post(message)
|
||||
|
||||
text_area.post_message = wrapped
|
||||
await pilot.press("ctrl+v")
|
||||
await pilot.pause()
|
||||
return has_binding, posted
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
importlib.reload(text_area_module)
|
||||
|
||||
|
||||
def test_read_clipboard_image_returns_none_when_no_readers(
|
||||
monkeypatch, _force_supported_platform
|
||||
) -> None:
|
||||
monkeypatch.setattr(paste_image, "_readers_for_platform", lambda: [])
|
||||
assert read_clipboard_image() is None
|
||||
|
||||
|
||||
def test_read_clipboard_image_skips_non_png_bytes(
|
||||
monkeypatch, _force_supported_platform
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
paste_image, "_readers_for_platform", lambda: [lambda: b"not-a-png"]
|
||||
)
|
||||
assert read_clipboard_image() is None
|
||||
|
||||
|
||||
def test_read_clipboard_image_returns_first_png(
|
||||
monkeypatch, _force_supported_platform
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
paste_image,
|
||||
"_readers_for_platform",
|
||||
lambda: [lambda: None, lambda: _FAKE_PNG, lambda: b"unused"],
|
||||
)
|
||||
assert read_clipboard_image() == _FAKE_PNG
|
||||
|
||||
|
||||
def test_read_clipboard_image_swallows_reader_exceptions(
|
||||
monkeypatch, _force_supported_platform
|
||||
) -> None:
|
||||
def _raises() -> bytes | None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(
|
||||
paste_image, "_readers_for_platform", lambda: [_raises, lambda: _FAKE_PNG]
|
||||
)
|
||||
assert read_clipboard_image() == _FAKE_PNG
|
||||
|
||||
|
||||
def test_macos_class_reader_returns_bytes_when_osascript_succeeds(monkeypatch) -> None:
|
||||
def fake_run(cmd, **kwargs):
|
||||
# osascript wrote the temp file before returning 0; mimic that.
|
||||
script_text = cmd[2]
|
||||
prefix = 'set targetFile to POSIX file "'
|
||||
start = script_text.index(prefix) + len(prefix)
|
||||
end = script_text.index('"', start)
|
||||
Path(script_text[start:end]).write_bytes(_FAKE_PNG)
|
||||
return _completed()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
assert _read_macos_class("PNGf") == _FAKE_PNG
|
||||
|
||||
|
||||
def test_macos_class_reader_returns_none_when_osascript_fails(monkeypatch) -> None:
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _completed(returncode=1))
|
||||
assert _read_macos_class("PNGf") is None
|
||||
|
||||
|
||||
def test_macos_reader_falls_back_to_tiff_when_png_missing(monkeypatch) -> None:
|
||||
fake_tiff = b"II*\x00fake-tiff"
|
||||
calls: list[str] = []
|
||||
# `sips` only exists on macOS; force the which() probe so the conversion
|
||||
# path runs (and hits the mocked subprocess.run) on Linux CI too.
|
||||
monkeypatch.setattr(paste_image.shutil, "which", lambda _name: "/usr/bin/sips")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "osascript":
|
||||
script_text = cmd[2]
|
||||
if "PNGf" in script_text:
|
||||
calls.append("PNGf")
|
||||
return _completed(returncode=1)
|
||||
calls.append("TIFF")
|
||||
prefix = 'set targetFile to POSIX file "'
|
||||
start = script_text.index(prefix) + len(prefix)
|
||||
end = script_text.index('"', start)
|
||||
Path(script_text[start:end]).write_bytes(fake_tiff)
|
||||
return _completed()
|
||||
# sips invocation
|
||||
calls.append("sips")
|
||||
src = Path(cmd[cmd.index("--out") - 1])
|
||||
out = Path(cmd[cmd.index("--out") + 1])
|
||||
# Mimic conversion: write a fake PNG to the --out path.
|
||||
assert src.read_bytes() == fake_tiff
|
||||
out.write_bytes(_FAKE_PNG)
|
||||
return _completed()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
assert _read_macos() == _FAKE_PNG
|
||||
assert calls == ["PNGf", "TIFF", "sips"]
|
||||
|
||||
|
||||
def test_reader_timeout_is_swallowed(monkeypatch) -> None:
|
||||
def fake_run(*args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd=args[0], timeout=1.0)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(paste_image, "_readers_for_platform", lambda: [_read_macos])
|
||||
monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True)
|
||||
assert read_clipboard_image() is None
|
||||
|
||||
|
||||
def test_read_clipboard_image_returns_none_on_unsupported_platform(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
paste_image, "is_clipboard_image_paste_supported", lambda: False
|
||||
)
|
||||
# Even if a reader were defined, the platform gate short-circuits.
|
||||
monkeypatch.setattr(
|
||||
paste_image, "_readers_for_platform", lambda: [lambda: _FAKE_PNG]
|
||||
)
|
||||
assert read_clipboard_image() is None
|
||||
|
||||
|
||||
def test_write_clipboard_image_uses_filename_without_whitespace(tmp_path) -> None:
|
||||
path = write_clipboard_image(_FAKE_PNG, session_dir=tmp_path)
|
||||
|
||||
assert path.parent == tmp_path / "attachments"
|
||||
assert not any(char.isspace() for char in path.name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_paste_posts_clipboard_image_pasted_message(
|
||||
vibe_app: VibeApp,
|
||||
) -> None:
|
||||
posted: list[ChatTextArea.ClipboardImagePasted] = []
|
||||
|
||||
def capture(message) -> None:
|
||||
if isinstance(message, ChatTextArea.ClipboardImagePasted):
|
||||
posted.append(message)
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
text_area = vibe_app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
original_post = text_area.post_message
|
||||
|
||||
def wrapped(message):
|
||||
capture(message)
|
||||
return original_post(message)
|
||||
|
||||
text_area.post_message = wrapped # type: ignore[method-assign]
|
||||
text_area.post_message(events.Paste(text=""))
|
||||
await pilot.pause()
|
||||
|
||||
assert len(posted) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_empty_paste_does_not_post_clipboard_image_pasted(
|
||||
vibe_app: VibeApp,
|
||||
) -> None:
|
||||
posted: list[ChatTextArea.ClipboardImagePasted] = []
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
text_area = vibe_app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
original_post = text_area.post_message
|
||||
|
||||
def wrapped(message):
|
||||
if isinstance(message, ChatTextArea.ClipboardImagePasted):
|
||||
posted.append(message)
|
||||
return original_post(message)
|
||||
|
||||
text_area.post_message = wrapped # type: ignore[method-assign]
|
||||
text_area.post_message(events.Paste(text="hello world"))
|
||||
await pilot.pause()
|
||||
|
||||
assert posted == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_clipboard_image_paste_writes_file_and_inserts_token() -> None:
|
||||
app = _build_vision_app()
|
||||
notifications: list[str] = []
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
|
||||
return_value=_FAKE_PNG,
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
with patch.object(
|
||||
app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg)
|
||||
):
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
await pilot.pause()
|
||||
chat_input = app.query_one(ChatInputContainer)
|
||||
value = chat_input.value
|
||||
assert value.startswith("@")
|
||||
assert value.endswith(".png ")
|
||||
assert "clipboard-" in value
|
||||
assert len(notifications) == 1
|
||||
assert notifications[0].startswith("Image pasted as clipboard-")
|
||||
assert notifications[0].endswith(".png (26 Bytes)")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_clipboard_image_paste_warns_when_token_insert_fails(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
app = _build_vision_app()
|
||||
path = tmp_path / "clipboard.png"
|
||||
notifications: list[tuple[str, str | None]] = []
|
||||
|
||||
def fake_write_clipboard_image(data: bytes, *, session_dir: Path | None) -> Path:
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
def fake_query_one(*_args, **_kwargs):
|
||||
raise RuntimeError("not mounted")
|
||||
|
||||
monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True)
|
||||
monkeypatch.setattr(paste_image, "read_clipboard_image", lambda: _FAKE_PNG)
|
||||
monkeypatch.setattr(
|
||||
paste_image, "write_clipboard_image", fake_write_clipboard_image
|
||||
)
|
||||
monkeypatch.setattr(app, "query_one", fake_query_one)
|
||||
monkeypatch.setattr(
|
||||
app,
|
||||
"notify",
|
||||
lambda msg, **kwargs: notifications.append((msg, kwargs.get("severity"))),
|
||||
)
|
||||
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
|
||||
assert not path.exists()
|
||||
assert notifications == [("Failed to paste image into prompt.", "warning")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_clipboard_image_paste_noop_when_clipboard_empty() -> None:
|
||||
app = _build_vision_app()
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
await pilot.pause()
|
||||
assert app.query_one(ChatInputContainer).value == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_clipboard_image_paste_blocks_when_model_no_vision() -> None:
|
||||
app = _build_vision_app(supports_images=False)
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
|
||||
return_value=_FAKE_PNG,
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
await pilot.pause()
|
||||
assert app.query_one(ChatInputContainer).value == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_clipboard_image_paste_rejects_oversize(monkeypatch) -> None:
|
||||
app = _build_vision_app()
|
||||
notifications: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.MAX_IMAGE_BYTES", 10
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
|
||||
return_value=_FAKE_PNG,
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
with patch.object(
|
||||
app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg)
|
||||
):
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
await pilot.pause()
|
||||
assert app.query_one(ChatInputContainer).value == ""
|
||||
assert notifications == ["Clipboard image is 26 Bytes; max is 10 Bytes."]
|
||||
|
||||
|
||||
def test_paste_image_slash_command_hidden_on_unsupported_platform(monkeypatch) -> None:
|
||||
from vibe.cli.commands import CommandRegistry
|
||||
|
||||
monkeypatch.setattr("platform.system", lambda: "Linux")
|
||||
registry = CommandRegistry()
|
||||
assert not registry.has_command("paste-image")
|
||||
|
||||
|
||||
def test_paste_image_slash_command_available_on_darwin(monkeypatch) -> None:
|
||||
from vibe.cli.commands import CommandRegistry
|
||||
|
||||
monkeypatch.setattr("platform.system", lambda: "Darwin")
|
||||
registry = CommandRegistry()
|
||||
assert registry.has_command("paste-image")
|
||||
|
||||
|
||||
def test_ctrl_v_binding_absent_on_unsupported_platform(monkeypatch) -> None:
|
||||
# Bindings are evaluated at class-definition time, so we have to
|
||||
# re-import the module after patching platform.system().
|
||||
import importlib
|
||||
|
||||
from vibe.cli.textual_ui.widgets.chat_input import text_area as text_area_module
|
||||
|
||||
monkeypatch.setattr("platform.system", lambda: "Linux")
|
||||
reloaded = importlib.reload(text_area_module)
|
||||
try:
|
||||
assert all(b.key != "ctrl+v" for b in reloaded.ChatTextArea.BINDINGS)
|
||||
finally:
|
||||
importlib.reload(text_area_module)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("notify_when_empty", [True, False])
|
||||
async def test_handle_clipboard_image_paste_is_silent_on_unsupported_platform(
|
||||
notify_when_empty: bool,
|
||||
) -> None:
|
||||
app = _build_vision_app()
|
||||
notifications: list[str] = []
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=False,
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
with patch.object(
|
||||
app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg)
|
||||
):
|
||||
await handle_clipboard_image_paste(
|
||||
app, notify_when_empty=notify_when_empty
|
||||
)
|
||||
await pilot.pause()
|
||||
assert notifications == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ctrl_v_keybinding_triggers_image_paste_with_notify_on_darwin(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
has_binding, posted = await _press_ctrl_v_under_platform(monkeypatch, "Darwin")
|
||||
assert has_binding
|
||||
assert len(posted) == 1
|
||||
assert posted[0].notify_when_empty is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ctrl_v_keybinding_does_not_paste_image_on_linux(monkeypatch) -> None:
|
||||
has_binding, posted = await _press_ctrl_v_under_platform(monkeypatch, "Linux")
|
||||
assert not has_binding
|
||||
assert posted == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_image_token_adds_leading_space_when_needed() -> None:
|
||||
app = _build_vision_app()
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
|
||||
return_value=_FAKE_PNG,
|
||||
),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.text = "look at this:"
|
||||
text_area.move_cursor((0, len("look at this:")))
|
||||
await handle_clipboard_image_paste(app, notify_when_empty=False)
|
||||
await pilot.pause()
|
||||
value = app.query_one(ChatInputContainer).value
|
||||
assert " @" in value and value.startswith("look at this: @")
|
||||
88
tests/cli/textual_ui/test_startup_prompt.py
Normal file
88
tests/cli/textual_ui/test_startup_prompt.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
from vibe.cli.plan_offer.decide_plan_offer import PlanInfo
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType
|
||||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_prompt_waits_for_startup_resume_picker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = build_test_vibe_app(initial_prompt="continue the work")
|
||||
app._show_resume_picker = True
|
||||
process_prompt = Mock()
|
||||
|
||||
monkeypatch.setattr(app, "_resolve_plan", AsyncMock())
|
||||
monkeypatch.setattr(app, "_check_and_show_whats_new", AsyncMock())
|
||||
monkeypatch.setattr(app, "_schedule_update_notification", Mock())
|
||||
monkeypatch.setattr(app, "_process_initial_prompt", process_prompt)
|
||||
|
||||
await app._complete_post_ready_startup()
|
||||
|
||||
process_prompt.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_prompt_runs_after_startup_resume_picker_selection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = build_test_vibe_app(initial_prompt="continue the work")
|
||||
app._show_resume_picker = True
|
||||
app._startup_command_availability_ready.set()
|
||||
process_prompt = Mock()
|
||||
|
||||
monkeypatch.setattr(app, "_switch_to_input_app", AsyncMock())
|
||||
monkeypatch.setattr(app, "_resume_local_session", AsyncMock())
|
||||
monkeypatch.setattr(app, "_process_initial_prompt", process_prompt)
|
||||
|
||||
await app.on_session_picker_app_session_selected(
|
||||
SessionPickerApp.SessionSelected("local:session-1", "session-1")
|
||||
)
|
||||
|
||||
assert app._show_resume_picker is False
|
||||
process_prompt.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_teleport_waits_for_plan_resolution_after_session_selection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = build_test_vibe_app(initial_prompt="continue the work")
|
||||
app._show_resume_picker = True
|
||||
app._teleport_on_start = True
|
||||
run_worker = Mock()
|
||||
handle_teleport = Mock(return_value=object())
|
||||
handle_user_message = Mock(return_value=object())
|
||||
|
||||
monkeypatch.setattr(app, "_switch_to_input_app", AsyncMock())
|
||||
monkeypatch.setattr(app, "_resume_local_session", AsyncMock())
|
||||
monkeypatch.setattr(app, "run_worker", run_worker)
|
||||
monkeypatch.setattr(app, "_handle_teleport_command", handle_teleport)
|
||||
monkeypatch.setattr(app, "_handle_user_message", handle_user_message)
|
||||
monkeypatch.setattr(app.commands, "has_command", lambda name: name == "teleport")
|
||||
|
||||
task = asyncio.create_task(
|
||||
app.on_session_picker_app_session_selected(
|
||||
SessionPickerApp.SessionSelected("local:session-1", "session-1")
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
handle_teleport.assert_not_called()
|
||||
handle_user_message.assert_not_called()
|
||||
|
||||
app._plan_info = PlanInfo(WhoAmIPlanType.CHAT)
|
||||
app._refresh_command_registry()
|
||||
app._startup_command_availability_ready.set()
|
||||
await task
|
||||
|
||||
handle_teleport.assert_called_once_with("continue the work")
|
||||
handle_user_message.assert_not_called()
|
||||
run_worker.assert_called_once_with(handle_teleport.return_value, exclusive=False)
|
||||
236
tests/cli/textual_ui/test_tool_result_link.py
Normal file
236
tests/cli/textual_ui/test_tool_result_link.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
|
||||
from vibe.cli.textual_ui.widgets.links import (
|
||||
LinkStatic,
|
||||
link_markup,
|
||||
linkify_urls_in_text,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import WebSearchResultWidget
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage
|
||||
from vibe.core.tools.builtins.websearch import WebSearchResult, WebSearchSource
|
||||
|
||||
|
||||
def test_link_markup_encodes_url_in_action_and_renders_label() -> None:
|
||||
# The action arg is percent-encoded; the visible label is the page name.
|
||||
assert (
|
||||
link_markup("Example", "https://example.com")
|
||||
== "[@click=open_url('https%3A%2F%2Fexample.com')]Example[/]"
|
||||
)
|
||||
|
||||
|
||||
def test_link_markup_only_links_http_schemes() -> None:
|
||||
# Non-http(s) schemes render as the plain label, not a clickable @click span.
|
||||
for url in ("file:///etc/passwd", "javascript:alert(1)", "vscode://x"):
|
||||
assert link_markup(url, url) == url
|
||||
assert "@click" not in link_markup(url, url)
|
||||
|
||||
|
||||
def test_link_markup_handles_previously_unsafe_urls() -> None:
|
||||
# Brackets, quotes, parens — all encoded into the action, never a fallback.
|
||||
for url in (
|
||||
"https://e.org/x[1]",
|
||||
"https://e.org/it's",
|
||||
"https://e.org/x)",
|
||||
"https://en.wikipedia.org/wiki/Python_(programming_language)",
|
||||
):
|
||||
markup = link_markup(url, url)
|
||||
assert markup.startswith("[@click=open_url('")
|
||||
assert "[1]" not in markup.split("]", 1)[0] # raw bracket not in the tag
|
||||
|
||||
|
||||
class _Harness(App):
|
||||
def __init__(self, url: str = "https://example.com") -> None:
|
||||
super().__init__()
|
||||
self.url = url
|
||||
self.opened: list[str] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield LinkStatic(link_markup(self.url, self.url))
|
||||
|
||||
def open_url(self, url: str, *, new_tab: bool = True) -> None:
|
||||
self.opened.append(url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clicking_link_span_opens_url() -> None:
|
||||
app = _Harness()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.click(LinkStatic)
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.opened == ["https://example.com"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://en.wikipedia.org/wiki/Python_(programming_language)",
|
||||
"https://httpbin.org/anything/test[1]",
|
||||
"https://e.org/it's",
|
||||
],
|
||||
)
|
||||
async def test_clicking_decodes_back_to_original_url(url: str) -> None:
|
||||
app = _Harness(url)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.click(LinkStatic)
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.opened == [url]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_open_url_ignores_non_http_scheme() -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
app = _Harness()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
app.query_one(LinkStatic).action_open_url(quote("file:///etc/passwd", safe=""))
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.opened == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scheme", ["javascript", "file", "data"])
|
||||
async def test_unsafe_schemes_are_rejected(scheme: str) -> None:
|
||||
app = _Harness(f"{scheme}:payload")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await pilot.click(LinkStatic)
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.opened == []
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_auto_detects_url() -> None:
|
||||
# Once a tool is opted into linkification, URLs are found in the message
|
||||
# itself — the call site doesn't have to point at the URL span.
|
||||
markup = linkify_urls_in_text("Fetched https://example.com (10 chars, text/html)")
|
||||
assert markup.startswith("Fetched ")
|
||||
assert (
|
||||
"[@click=open_url('https%3A%2F%2Fexample.com')]https://example.com[/]" in markup
|
||||
)
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_handles_multiple_urls() -> None:
|
||||
markup = linkify_urls_in_text("see https://a.com and https://b.com here")
|
||||
assert "[@click=open_url('https%3A%2F%2Fa.com')]https://a.com[/]" in markup
|
||||
assert "[@click=open_url('https%3A%2F%2Fb.com')]https://b.com[/]" in markup
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_keeps_balanced_parens_in_url() -> None:
|
||||
# Wikipedia-style URLs with `(…)` were the reason the @click action is
|
||||
# percent-encoded; Rich's URL detector already keeps them in the span.
|
||||
url = "https://en.wikipedia.org/wiki/Python_(programming_language)"
|
||||
markup = linkify_urls_in_text(f"see {url} for details")
|
||||
assert link_markup(url, url) in markup
|
||||
|
||||
|
||||
def test_linkify_urls_in_text_escapes_and_keeps_plain_when_no_url() -> None:
|
||||
# Brackets must be escaped so raw tool text can't break the markup.
|
||||
assert (
|
||||
linkify_urls_in_text("Searched '[a]' (2 sources)")
|
||||
== "Searched '\\[a]' (2 sources)"
|
||||
)
|
||||
|
||||
|
||||
async def _rendered_lines(widget: WebSearchResultWidget) -> list[str]:
|
||||
from textual.widgets import Static
|
||||
|
||||
class _H(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield widget
|
||||
|
||||
async with _H().run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
return [str(w.render()) for w in widget.query(Static)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websearch_single_source_is_bulleted_without_header() -> None:
|
||||
result = WebSearchResult(
|
||||
query="uv",
|
||||
answer="ans",
|
||||
sources=[WebSearchSource(title="Docs", url="https://x.com")],
|
||||
)
|
||||
lines = await _rendered_lines(
|
||||
WebSearchResultWidget(result, success=True, message="m")
|
||||
)
|
||||
joined = "\n".join(lines)
|
||||
|
||||
assert "• Docs" in joined # bulleted, page name as the link label
|
||||
assert "https://x.com" not in joined # url lives in the @click action, not text
|
||||
assert "Source:" not in joined # singular "Source:" prefix dropped
|
||||
assert "Sources:" not in joined # no header for a lone source
|
||||
assert "WebSearchSource(" not in joined # raw `sources: [...]` dump dropped
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websearch_multiple_sources_is_bulleted_plural() -> None:
|
||||
result = WebSearchResult(
|
||||
query="uv",
|
||||
answer="ans",
|
||||
sources=[
|
||||
WebSearchSource(title="A", url="https://a.com"),
|
||||
WebSearchSource(title="B", url="https://b.com"),
|
||||
],
|
||||
)
|
||||
lines = await _rendered_lines(
|
||||
WebSearchResultWidget(result, success=True, message="m")
|
||||
)
|
||||
joined = "\n".join(lines)
|
||||
|
||||
assert "Sources:" in joined
|
||||
assert "• A" in joined
|
||||
assert "• B" in joined
|
||||
assert "https://a.com" not in joined # url lives in the @click action, not text
|
||||
assert "WebSearchSource(" not in joined
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_message_set_result_text_renders_clickable_url() -> None:
|
||||
call = ToolCallMessage(tool_name="web_fetch")
|
||||
|
||||
class _H(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield call
|
||||
|
||||
async with _H().run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
call.set_result_text(
|
||||
"Fetched https://example.com (10 chars, text/html)", linkify=True
|
||||
)
|
||||
await pilot.pause(0.1)
|
||||
rendered = str(call._text_widget.render()) if call._text_widget else ""
|
||||
|
||||
# The URL becomes a clickable span in the status line; surrounding text stays.
|
||||
assert "https://example.com" in rendered
|
||||
assert "Fetched" in rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_message_set_result_text_escapes_when_linkify_off() -> None:
|
||||
call = ToolCallMessage(tool_name="bash")
|
||||
|
||||
class _H(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield call
|
||||
|
||||
async with _H().run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
# Bash isn't in the linkify whitelist; URLs must stay plain text and
|
||||
# brackets in the message must not be interpreted as markup.
|
||||
call.set_result_text("ran: see https://example.com [exit 0]")
|
||||
await pilot.pause(0.1)
|
||||
rendered = str(call._text_widget.render()) if call._text_widget else ""
|
||||
|
||||
assert "@click=open_url" not in rendered
|
||||
assert "https://example.com" in rendered
|
||||
assert "[exit 0]" in rendered
|
||||
|
|
@ -1,11 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Link
|
||||
|
||||
from vibe.cli.textual_ui.widgets.messages import UserMessage
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.windowing.history import build_history_widgets
|
||||
from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role
|
||||
from vibe.core.types import (
|
||||
FileImageSource,
|
||||
ImageAttachment,
|
||||
InlineImageSource,
|
||||
LLMMessage,
|
||||
Role,
|
||||
)
|
||||
|
||||
|
||||
def _att(path: Path, alias: str) -> ImageAttachment:
|
||||
|
|
@ -15,35 +27,92 @@ def _att(path: Path, alias: str) -> ImageAttachment:
|
|||
)
|
||||
|
||||
|
||||
def test_attachments_footer_singular(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "shot.png")
|
||||
class _UserMessageApp(App[None]):
|
||||
CSS = """
|
||||
.user-message-wrapper,
|
||||
.user-message-container,
|
||||
.user-message-attachments,
|
||||
.user-message-attachment-line {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([att])
|
||||
.user-message-attachment-label,
|
||||
.user-message-attachment-link {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
assert "attached image:" in rendered
|
||||
assert '[link="file://' in rendered
|
||||
assert "]shot.png[/link]" in rendered
|
||||
def __init__(self, message: UserMessage) -> None:
|
||||
super().__init__()
|
||||
self._message = message
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield self._message
|
||||
|
||||
|
||||
def test_attachments_footer_plural(tmp_path: Path) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachments_render_one_clickable_link_per_image(tmp_path: Path) -> None:
|
||||
a = _att(tmp_path / "a.png", "a.png")
|
||||
b = _att(tmp_path / "b.png", "b.png")
|
||||
app = _UserMessageApp(UserMessage("look", images=[a, b]))
|
||||
opened: list[str] = []
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([a, b])
|
||||
a_uri = (tmp_path / "a.png").as_uri()
|
||||
b_uri = (tmp_path / "b.png").as_uri()
|
||||
|
||||
assert "attached images:" in rendered
|
||||
assert rendered.count('[link="file://') == 2
|
||||
assert "a.png" in rendered
|
||||
assert "b.png" in rendered
|
||||
async with app.run_test() as pilot:
|
||||
links = list(app.query(Link))
|
||||
assert [link.text for link in links] == ["a.png", "b.png"]
|
||||
assert [link.url for link in links] == [a_uri, b_uri]
|
||||
|
||||
with patch.object(
|
||||
app, "open_url", side_effect=lambda url, **_kwargs: opened.append(url)
|
||||
):
|
||||
await pilot.click(links[0])
|
||||
|
||||
assert opened == [a_uri]
|
||||
|
||||
|
||||
def test_attachments_footer_escapes_alias_brackets(tmp_path: Path) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_attachment_renders_label_without_link() -> None:
|
||||
att = ImageAttachment(
|
||||
source=InlineImageSource(data="aW1n"), alias="pasted.png", mime_type="image/png"
|
||||
)
|
||||
app = _UserMessageApp(UserMessage("look", images=[att]))
|
||||
|
||||
async with app.run_test():
|
||||
assert list(app.query(Link)) == []
|
||||
label = app.query_one(".user-message-attachment-link")
|
||||
|
||||
assert isinstance(label, NoMarkupStatic)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_link_renders_alias_brackets_literally(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "weird [bracket].png")
|
||||
app = _UserMessageApp(UserMessage("look", images=[att]))
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([att])
|
||||
async with app.run_test():
|
||||
link = app.query_one(Link)
|
||||
|
||||
# Rich's escape() turns "[" into "\[".
|
||||
assert "\\[bracket]" in rendered
|
||||
assert link.text == "weird [bracket].png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_link_shortens_home_absolute_alias() -> None:
|
||||
path = Path.home() / "Pictures" / "shot.png"
|
||||
att = ImageAttachment(
|
||||
source=FileImageSource(path=path), alias=str(path), mime_type="image/png"
|
||||
)
|
||||
app = _UserMessageApp(UserMessage("look", images=[att]))
|
||||
|
||||
async with app.run_test():
|
||||
link = app.query_one(Link)
|
||||
|
||||
assert link.text == "~/Pictures/shot.png"
|
||||
assert link.url == path.as_uri()
|
||||
|
||||
|
||||
def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue