2.1.0 (#317)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Clément Siriex <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Nicolas Karolak <nicolas@karolak.fr>
This commit is contained in:
parent
9809cfc831
commit
51fecc67d9
176 changed files with 8652 additions and 4451 deletions
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
import logging
|
||||
from os import environ
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -9,8 +11,23 @@ from vibe.cli.plan_offer.decide_plan_offer import (
|
|||
PlanOfferAction,
|
||||
PlanType,
|
||||
decide_plan_offer,
|
||||
resolve_api_key_for_plan,
|
||||
)
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||
from vibe.core.config import Backend, ProviderConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mistral_api_key_env() -> Generator[str, None, None]:
|
||||
original_value = environ.get("MISTRAL_API_KEY")
|
||||
test_api_key = "test_mistral_api_key"
|
||||
environ["MISTRAL_API_KEY"] = test_api_key
|
||||
yield test_api_key
|
||||
|
||||
if original_value is not None:
|
||||
environ["MISTRAL_API_KEY"] = original_value
|
||||
else:
|
||||
del environ["MISTRAL_API_KEY"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -115,3 +132,51 @@ async def test_proposes_none_and_logs_warning_when_gateway_error_occurs(
|
|||
assert plan_type is PlanType.UNKNOWN
|
||||
assert gateway.calls == ["api-key"]
|
||||
assert "Failed to fetch plan status." in caplog.text
|
||||
|
||||
|
||||
def test_resolve_api_key_for_plan_with_mistral_backend(
|
||||
mistral_api_key_env: str,
|
||||
) -> None:
|
||||
test_api_key = mistral_api_key_env
|
||||
|
||||
provider = ProviderConfig(
|
||||
name="test_mistral",
|
||||
api_base="https://api.mistral.ai",
|
||||
backend=Backend.MISTRAL,
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
)
|
||||
|
||||
result = resolve_api_key_for_plan(provider)
|
||||
assert result == test_api_key
|
||||
|
||||
|
||||
def test_resolve_api_key_for_plan_with_non_mistral_backend(
|
||||
mistral_api_key_env: str,
|
||||
) -> None:
|
||||
provider = ProviderConfig(
|
||||
name="test_generic",
|
||||
api_base="https://api.generic.ai",
|
||||
backend=Backend.GENERIC,
|
||||
api_key_env_var="GENERIC_API_KEY",
|
||||
)
|
||||
|
||||
result = resolve_api_key_for_plan(provider)
|
||||
assert result == mistral_api_key_env
|
||||
|
||||
|
||||
def test_resolve_api_key_for_plan_with_missing_env_var() -> None:
|
||||
previous_api_key = environ["MISTRAL_API_KEY"]
|
||||
del environ["MISTRAL_API_KEY"]
|
||||
|
||||
provider = ProviderConfig(
|
||||
name="test_mistral",
|
||||
api_base="https://api.mistral.ai",
|
||||
backend=Backend.MISTRAL,
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
)
|
||||
|
||||
result = resolve_api_key_for_plan(provider)
|
||||
assert result is None
|
||||
|
||||
if previous_api_key is not None:
|
||||
environ["MISTRAL_API_KEY"] = previous_api_key
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.messages import PlanOfferMessage
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
|
||||
|
||||
def _make_app(gateway: FakeWhoAmIGateway, config: VibeConfig | None = None) -> VibeApp:
|
||||
config = config or VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
|
||||
)
|
||||
agent_loop = AgentLoop(config=config, backend=FakeBackend())
|
||||
return VibeApp(agent_loop=agent_loop, plan_offer_gateway=gateway)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_shows_upgrade_offer_in_plan_offer_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
|
||||
gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=True,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
)
|
||||
)
|
||||
app = _make_app(gateway)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
offer = app.query_one(PlanOfferMessage)
|
||||
assert "Upgrade to" in offer.get_text()
|
||||
assert "Pro" in offer.get_text()
|
||||
assert gateway.calls == ["api-key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_shows_switch_to_pro_key_offer_in_plan_offer_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
|
||||
gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=False,
|
||||
prompt_switching_to_pro_plan=True,
|
||||
)
|
||||
)
|
||||
app = _make_app(gateway)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
offer = app.query_one(PlanOfferMessage)
|
||||
assert "Switch to your" in offer.get_text()
|
||||
assert "Pro API key" in offer.get_text()
|
||||
assert gateway.calls == ["api-key"]
|
||||
114
tests/cli/test_braille_renderer.py
Normal file
114
tests/cli/test_braille_renderer.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli.textual_ui.widgets.braille_renderer import (
|
||||
_braille_char_from_dot_indices,
|
||||
_braille_dot_index,
|
||||
render_braille,
|
||||
)
|
||||
|
||||
|
||||
class TestBrailleDotIndex:
|
||||
"""Tests for _braille_dot_index(x, y)."""
|
||||
|
||||
def test_dot_positions_per_docstring(self) -> None:
|
||||
# Layout from docstring: x in {0,1}, y in {0,1,2,3}
|
||||
# -x-> | 1 4 | 2 5 | 3 6 | V 7 8
|
||||
assert _braille_dot_index(0, 0) == 1
|
||||
assert _braille_dot_index(1, 0) == 4
|
||||
assert _braille_dot_index(0, 1) == 2
|
||||
assert _braille_dot_index(1, 1) == 5
|
||||
assert _braille_dot_index(0, 2) == 3
|
||||
assert _braille_dot_index(1, 2) == 6
|
||||
assert _braille_dot_index(0, 3) == 7
|
||||
assert _braille_dot_index(1, 3) == 8
|
||||
|
||||
@pytest.mark.parametrize("x", [0, 1])
|
||||
@pytest.mark.parametrize("y", [0, 1, 2, 3])
|
||||
def test_all_indices_in_range_one_to_eight(self, x: int, y: int) -> None:
|
||||
idx = _braille_dot_index(x, y)
|
||||
assert 1 <= idx <= 8
|
||||
|
||||
|
||||
class TestBrailleCharFromDotIndices:
|
||||
"""Tests for _braille_char_from_dot_indices."""
|
||||
|
||||
def test_empty_indices_returns_space(self) -> None:
|
||||
assert _braille_char_from_dot_indices([]) == " "
|
||||
|
||||
def test_single_dot_one_returns_braille_char(self) -> None:
|
||||
# U+2800 is empty, +1 for dot 1 = U+2801
|
||||
char = _braille_char_from_dot_indices([1])
|
||||
assert char == "⠁"
|
||||
|
||||
def test_all_dots_returns_full_cell(self) -> None:
|
||||
char = _braille_char_from_dot_indices([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
# Full block: 0x2800 + (2^8 - 1) = 0x28FF
|
||||
assert char == "⣿"
|
||||
|
||||
def test_invalid_index_below_one_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="Invalid braille dot indices"):
|
||||
_braille_char_from_dot_indices([0])
|
||||
|
||||
def test_invalid_index_above_eight_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="Invalid braille dot indices"):
|
||||
_braille_char_from_dot_indices([9])
|
||||
|
||||
def test_order_of_indices_does_not_matter(self) -> None:
|
||||
a = _braille_char_from_dot_indices([1, 2, 3])
|
||||
b = _braille_char_from_dot_indices([3, 1, 2])
|
||||
assert a == b
|
||||
|
||||
|
||||
class TestRenderBraille:
|
||||
"""Tests for render_braille(dot_coords, width, height)."""
|
||||
|
||||
def test_empty_coords_produces_blank_grid(self) -> None:
|
||||
result = render_braille([], width=4, height=2)
|
||||
assert result == " "
|
||||
|
||||
def test_origin_one_dot(self) -> None:
|
||||
# (0, 0) -> first cell, dot 1
|
||||
result = render_braille([0], width=2, height=4)
|
||||
lines = result.split("\n")
|
||||
assert len(lines) == 1
|
||||
assert lines[0][0] == "⠁"
|
||||
assert lines[0].strip() == "⠁"
|
||||
|
||||
def test_output_dimensions_match_ceiled_width_and_height(self) -> None:
|
||||
result = render_braille([], width=3, height=7)
|
||||
lines = result.split("\n")
|
||||
assert len(lines) == 2
|
||||
assert len(lines[0]) == 2
|
||||
|
||||
def test_multiple_dots_in_same_cell_combine(self) -> None:
|
||||
# Two dots in first cell: (0,0) and (1,0) -> indices 1 and 4
|
||||
result = render_braille([0, 1], width=4, height=4)
|
||||
lines = result.split("\n")
|
||||
assert len(lines) == 1
|
||||
assert lines[0][0] == "⠉"
|
||||
|
||||
def test_dots_in_different_cells(self) -> None:
|
||||
# First cell (0,0), second cell (2,0)
|
||||
result = render_braille([0, 2], width=4, height=4)
|
||||
lines = result.split("\n")
|
||||
assert len(lines) == 1
|
||||
assert len(lines[0]) == 2
|
||||
assert lines[0][0] == "⠁"
|
||||
assert lines[0][1] == "⠁"
|
||||
|
||||
def test_multiple_rows(self) -> None:
|
||||
# Dot at (0,0) and (0,4) -> two rows
|
||||
result = render_braille([0, 4j], width=2, height=8)
|
||||
lines = result.split("\n")
|
||||
assert len(lines) == 2
|
||||
assert lines[0][0] == "⠁"
|
||||
assert lines[1][0] == "⠁"
|
||||
|
||||
def test_accepts_complex_coords(self) -> None:
|
||||
result = render_braille([1 + 2j], width=4, height=4)
|
||||
lines = result.split("\n")
|
||||
assert len(lines) == 1
|
||||
# x=1,y=2 -> first cell (0,0), sub_x=1, sub_y=2 -> dot index 6
|
||||
assert lines[0][0] == _braille_char_from_dot_indices([6])
|
||||
|
|
@ -5,17 +5,10 @@ from types import SimpleNamespace
|
|||
from typing import cast
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pyperclip
|
||||
import pytest
|
||||
from textual.app import App
|
||||
|
||||
from vibe.cli.clipboard import (
|
||||
_copy_osc52,
|
||||
_copy_wayland_clipboard,
|
||||
_copy_x11_clipboard,
|
||||
_get_copy_fns,
|
||||
copy_selection_to_clipboard,
|
||||
)
|
||||
from vibe.cli.clipboard import _copy_osc52, copy_selection_to_clipboard
|
||||
|
||||
|
||||
class MockWidget:
|
||||
|
|
@ -42,7 +35,6 @@ def mock_app() -> App:
|
|||
app = MagicMock(spec=App)
|
||||
app.query = MagicMock(return_value=[])
|
||||
app.notify = MagicMock()
|
||||
app.copy_to_clipboard = MagicMock()
|
||||
return cast(App, app)
|
||||
|
||||
|
||||
|
|
@ -86,21 +78,18 @@ def test_copy_selection_to_clipboard_no_notification(
|
|||
mock_app.notify.assert_not_called()
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard._get_copy_fns")
|
||||
@patch("vibe.cli.clipboard._copy_osc52")
|
||||
def test_copy_selection_to_clipboard_success(
|
||||
mock_get_copy_fns: MagicMock, mock_app: MagicMock
|
||||
mock_copy_osc52: MagicMock, mock_app: MagicMock
|
||||
) -> None:
|
||||
widget = MockWidget(
|
||||
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
||||
)
|
||||
mock_app.query.return_value = [widget]
|
||||
|
||||
mock_copy_fn = MagicMock()
|
||||
mock_get_copy_fns.return_value = [mock_copy_fn]
|
||||
|
||||
copy_selection_to_clipboard(mock_app)
|
||||
|
||||
mock_copy_fn.assert_called_once_with("selected text")
|
||||
mock_copy_osc52.assert_called_once_with("selected text")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
'"selected text" copied to clipboard',
|
||||
severity="information",
|
||||
|
|
@ -109,54 +98,22 @@ def test_copy_selection_to_clipboard_success(
|
|||
)
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard._get_copy_fns")
|
||||
def test_copy_selection_to_clipboard_tries_all(
|
||||
mock_get_copy_fns: MagicMock, mock_app: MagicMock
|
||||
@patch("vibe.cli.clipboard._copy_osc52")
|
||||
def test_copy_selection_to_clipboard_failure(
|
||||
mock_copy_osc52: MagicMock, mock_app: MagicMock
|
||||
) -> None:
|
||||
widget = MockWidget(
|
||||
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
||||
)
|
||||
mock_app.query.return_value = [widget]
|
||||
|
||||
fn_1 = MagicMock(side_effect=Exception("failed"))
|
||||
fn_2 = MagicMock()
|
||||
fn_3 = MagicMock()
|
||||
mock_get_copy_fns.return_value = [fn_1, fn_2, fn_3]
|
||||
mock_copy_osc52.side_effect = Exception("OSC52 failed")
|
||||
|
||||
copy_selection_to_clipboard(mock_app)
|
||||
|
||||
fn_1.assert_called_once_with("selected text")
|
||||
fn_2.assert_called_once_with("selected text")
|
||||
fn_3.assert_called_once_with("selected text")
|
||||
mock_copy_osc52.assert_called_once_with("selected text")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
'"selected text" copied to clipboard',
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
)
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard._get_copy_fns")
|
||||
def test_copy_selection_to_clipboard_all_methods_fail(
|
||||
mock_get_copy_fns: MagicMock, mock_app: MagicMock
|
||||
) -> None:
|
||||
widget = MockWidget(
|
||||
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
||||
)
|
||||
mock_app.query.return_value = [widget]
|
||||
|
||||
failing_fn1 = MagicMock(side_effect=Exception("failed 1"))
|
||||
failing_fn2 = MagicMock(side_effect=Exception("failed 2"))
|
||||
failing_fn3 = MagicMock(side_effect=Exception("failed 3"))
|
||||
mock_get_copy_fns.return_value = [failing_fn1, failing_fn2, failing_fn3]
|
||||
|
||||
copy_selection_to_clipboard(mock_app)
|
||||
|
||||
failing_fn1.assert_called_once_with("selected text")
|
||||
failing_fn2.assert_called_once_with("selected text")
|
||||
failing_fn3.assert_called_once_with("selected text")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
"Failed to copy - no clipboard method available", severity="warning", timeout=3
|
||||
"Failed to copy - clipboard not available", severity="warning", timeout=3
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -171,14 +128,12 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
|
|||
widget3 = MockWidget(text_selection=None)
|
||||
mock_app.query.return_value = [widget1, widget2, widget3]
|
||||
|
||||
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
|
||||
mock_copy_fn = MagicMock()
|
||||
mock_get_copy_fns.return_value = [mock_copy_fn]
|
||||
with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52:
|
||||
copy_selection_to_clipboard(mock_app)
|
||||
|
||||
mock_copy_fn.assert_called_once_with("first selection\nsecond selection")
|
||||
mock_copy_osc52.assert_called_once_with("first selection\nsecond selection")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
'"first selection⏎second selection" copied to clipboard',
|
||||
'"first selection\u23cesecond selection" copied to clipboard',
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
|
|
@ -192,12 +147,10 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) ->
|
|||
)
|
||||
mock_app.query.return_value = [widget]
|
||||
|
||||
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
|
||||
mock_copy_fn = MagicMock()
|
||||
mock_get_copy_fns.return_value = [mock_copy_fn]
|
||||
with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52:
|
||||
copy_selection_to_clipboard(mock_app)
|
||||
|
||||
mock_copy_fn.assert_called_once_with(long_text)
|
||||
mock_copy_osc52.assert_called_once_with(long_text)
|
||||
notification_call = mock_app.notify.call_args
|
||||
assert notification_call is not None
|
||||
assert '"' in notification_call[0][0]
|
||||
|
|
@ -210,7 +163,7 @@ def test_copy_osc52_writes_correct_sequence(
|
|||
mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("TMUX", raising=False)
|
||||
test_text = "héllo wörld 🎉"
|
||||
test_text = "hello world"
|
||||
|
||||
_copy_osc52(test_text)
|
||||
|
||||
|
|
@ -237,109 +190,16 @@ def test_copy_osc52_with_tmux(
|
|||
handle.write.assert_called_once_with(expected_seq)
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard.subprocess.run")
|
||||
def test_copy_x11_clipboard(mock_subprocess: MagicMock) -> None:
|
||||
test_text = "test text"
|
||||
|
||||
_copy_x11_clipboard(test_text)
|
||||
|
||||
mock_subprocess.assert_called_once_with(
|
||||
["xclip", "-selection", "clipboard"],
|
||||
input=test_text.encode("utf-8"),
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard.subprocess.run")
|
||||
def test_copy_wayland_clipboard(mock_subprocess: MagicMock) -> None:
|
||||
test_text = "test text"
|
||||
|
||||
_copy_wayland_clipboard(test_text)
|
||||
|
||||
mock_subprocess.assert_called_once_with(
|
||||
["wl-copy"], input=test_text.encode("utf-8"), check=True
|
||||
)
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard.shutil.which")
|
||||
def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> None:
|
||||
mock_which.return_value = None
|
||||
|
||||
copy_fns = _get_copy_fns(mock_app)
|
||||
|
||||
assert len(copy_fns) == 3
|
||||
assert copy_fns[0] == _copy_osc52
|
||||
assert copy_fns[1] == pyperclip.copy
|
||||
assert copy_fns[2] == mock_app.copy_to_clipboard
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard.platform.system")
|
||||
@patch("vibe.cli.clipboard.shutil.which")
|
||||
def test_get_copy_fns_with_xclip(
|
||||
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
|
||||
@patch("builtins.open", new_callable=mock_open)
|
||||
def test_copy_osc52_unicode(
|
||||
mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
mock_platform_system.return_value = "Linux"
|
||||
monkeypatch.delenv("TMUX", raising=False)
|
||||
test_text = "hello world"
|
||||
|
||||
def which_side_effect(cmd: str) -> str | None:
|
||||
return "/usr/bin/xclip" if cmd == "xclip" else None
|
||||
_copy_osc52(test_text)
|
||||
|
||||
mock_which.side_effect = which_side_effect
|
||||
|
||||
copy_fns = _get_copy_fns(mock_app)
|
||||
|
||||
assert len(copy_fns) == 4
|
||||
assert copy_fns[0] == _copy_x11_clipboard
|
||||
assert copy_fns[1] == _copy_osc52
|
||||
assert copy_fns[2] == pyperclip.copy
|
||||
assert copy_fns[3] == mock_app.copy_to_clipboard
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard.platform.system")
|
||||
@patch("vibe.cli.clipboard.shutil.which")
|
||||
def test_get_copy_fns_with_wl_copy(
|
||||
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
|
||||
) -> None:
|
||||
mock_platform_system.return_value = "Linux"
|
||||
|
||||
def which_side_effect(cmd: str) -> str | None:
|
||||
return "/usr/bin/wl-copy" if cmd == "wl-copy" else None
|
||||
|
||||
mock_which.side_effect = which_side_effect
|
||||
|
||||
copy_fns = _get_copy_fns(mock_app)
|
||||
|
||||
assert len(copy_fns) == 4
|
||||
assert copy_fns[0] == _copy_wayland_clipboard
|
||||
assert copy_fns[1] == _copy_osc52
|
||||
assert copy_fns[2] == pyperclip.copy
|
||||
assert copy_fns[3] == mock_app.copy_to_clipboard
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard.platform.system")
|
||||
@patch("vibe.cli.clipboard.shutil.which")
|
||||
def test_get_copy_fns_with_both_system_tools(
|
||||
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
|
||||
) -> None:
|
||||
mock_platform_system.return_value = "Linux"
|
||||
|
||||
def which_side_effect(cmd: str) -> str | None:
|
||||
match cmd:
|
||||
case "wl-copy":
|
||||
return "/usr/bin/wl-copy"
|
||||
case "xclip":
|
||||
return "/usr/bin/xclip"
|
||||
case _:
|
||||
return None
|
||||
|
||||
mock_which.side_effect = which_side_effect
|
||||
|
||||
copy_fns = _get_copy_fns(mock_app)
|
||||
|
||||
assert len(copy_fns) == 5
|
||||
# xclip is checked last, so it's added last and ends up first in the list
|
||||
assert copy_fns[0] == _copy_x11_clipboard
|
||||
# wl-copy is checked first, so it's added before xclip
|
||||
assert copy_fns[1] == _copy_wayland_clipboard
|
||||
assert copy_fns[2] == _copy_osc52
|
||||
assert copy_fns[3] == pyperclip.copy
|
||||
assert copy_fns[4] == mock_app.copy_to_clipboard
|
||||
encoded = base64.b64encode(test_text.encode("utf-8")).decode("ascii")
|
||||
expected_seq = f"\033]52;c;{encoded}\a"
|
||||
handle = mock_file()
|
||||
handle.write.assert_called_once_with(expected_seq)
|
||||
|
|
|
|||
59
tests/cli/test_copy_shortcuts.py
Normal file
59
tests/cli/test_copy_shortcuts.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ctrl_y_triggers_copy_selection() -> None:
|
||||
"""Test that ctrl+y keybinding triggers copy_selection_to_clipboard."""
|
||||
app = BaseSnapshotTestApp()
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("ctrl+y")
|
||||
mock_copy.assert_called_once_with(app, show_toast=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ctrl_shift_c_triggers_copy_selection() -> None:
|
||||
"""Test that ctrl+shift+c keybinding triggers copy_selection_to_clipboard."""
|
||||
app = BaseSnapshotTestApp()
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("ctrl+shift+c")
|
||||
mock_copy.assert_called_once_with(app, show_toast=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mouse_up_respects_autocopy_config_enabled() -> None:
|
||||
"""Test that mouse up copies when autocopy_to_clipboard is True."""
|
||||
from tests.snapshots.base_snapshot_test_app import default_config
|
||||
|
||||
config = default_config()
|
||||
config.autocopy_to_clipboard = True
|
||||
app = BaseSnapshotTestApp(config=config)
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.click()
|
||||
mock_copy.assert_called_once_with(app, show_toast=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mouse_up_respects_autocopy_config_disabled() -> None:
|
||||
"""Test that mouse up does not copy when autocopy_to_clipboard is False."""
|
||||
from tests.snapshots.base_snapshot_test_app import default_config
|
||||
|
||||
config = default_config()
|
||||
config.autocopy_to_clipboard = False
|
||||
app = BaseSnapshotTestApp(config=config)
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.click()
|
||||
mock_copy.assert_not_called()
|
||||
16
tests/cli/test_spinner.py
Normal file
16
tests/cli/test_spinner.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerType, create_spinner
|
||||
|
||||
|
||||
def test_generate_100_frames_no_crash() -> None:
|
||||
"""Generate 100 frames per spinner type with seeded random for determinism."""
|
||||
random.seed(42)
|
||||
for spinner_type in SpinnerType:
|
||||
spinner = create_spinner(spinner_type)
|
||||
for _ in range(100):
|
||||
frame = spinner.next_frame()
|
||||
assert isinstance(frame, str)
|
||||
assert len(frame) > 0
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from textual.selection import Selection
|
||||
|
|
@ -8,8 +8,6 @@ from textual.widget import Widget
|
|||
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
|
||||
|
||||
class ClipboardSelectionWidget(Widget):
|
||||
|
|
@ -27,24 +25,15 @@ class ClipboardSelectionWidget(Widget):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_clipboard_notification_does_not_crash_on_markup_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
monkeypatch: pytest.MonkeyPatch, vibe_app: VibeApp
|
||||
) -> None:
|
||||
agent_loop = AgentLoop(
|
||||
config=VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False),
|
||||
enable_update_checks=False,
|
||||
)
|
||||
)
|
||||
app = VibeApp(agent_loop=agent_loop)
|
||||
|
||||
async with app.run_test(notifications=True) as pilot:
|
||||
await app.mount(ClipboardSelectionWidget("[/]"))
|
||||
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
|
||||
mock_get_copy_fns.return_value = [MagicMock()]
|
||||
copy_selection_to_clipboard(app)
|
||||
async with vibe_app.run_test(notifications=True) as pilot:
|
||||
await vibe_app.mount(ClipboardSelectionWidget("[/]"))
|
||||
with patch("vibe.cli.clipboard._copy_osc52"):
|
||||
copy_selection_to_clipboard(vibe_app)
|
||||
|
||||
await pilot.pause(0.1)
|
||||
notifications = list(app._notifications)
|
||||
notifications = list(vibe_app._notifications)
|
||||
assert notifications
|
||||
notification = notifications[-1]
|
||||
assert notification.markup is False
|
||||
|
|
|
|||
172
tests/cli/test_ui_session_incremental_renderer.py
Normal file
172
tests/cli/test_ui_session_incremental_renderer.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from textual.widgets import Button
|
||||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from tests.conftest import build_test_agent_loop
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||
from vibe.cli.textual_ui.app import ChatScroll, VibeApp
|
||||
from vibe.cli.textual_ui.widgets.load_more import (
|
||||
HistoryLoadMoreMessage,
|
||||
HistoryLoadMoreRequested,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.messages import UserMessage
|
||||
from vibe.cli.textual_ui.windowing import (
|
||||
HISTORY_RESUME_TAIL_MESSAGES,
|
||||
LOAD_MORE_BATCH_SIZE,
|
||||
)
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vibe_config() -> VibeConfig:
|
||||
return VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
|
||||
)
|
||||
|
||||
|
||||
def _pro_plan_gateway() -> FakeWhoAmIGateway:
|
||||
return FakeWhoAmIGateway(
|
||||
response=WhoAmIResponse(
|
||||
is_pro_plan=True,
|
||||
advertise_pro_plan=False,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _wait_until(pause, predicate, timeout: float = 2.0) -> None:
|
||||
start = time.monotonic()
|
||||
while (time.monotonic() - start) < timeout:
|
||||
if predicate():
|
||||
return
|
||||
await pause(0.02)
|
||||
raise AssertionError("Condition was not met within the timeout")
|
||||
|
||||
|
||||
async def _wait_for_load_more(app: VibeApp, pause) -> None:
|
||||
await _wait_until(
|
||||
pause, lambda: len(app.query(HistoryLoadMoreMessage)) == 1, timeout=5.0
|
||||
)
|
||||
|
||||
|
||||
def _load_more_remaining(app: VibeApp) -> int:
|
||||
label = app.query_one(HistoryLoadMoreMessage).query_one(Button).label
|
||||
text = str(label)
|
||||
_, _, remainder = text.rpartition("(")
|
||||
return int(remainder.rstrip(")"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_session_incremental_loader_shows_tail_and_load_more(
|
||||
vibe_config: VibeConfig,
|
||||
) -> None:
|
||||
agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False)
|
||||
agent_loop.messages.extend([
|
||||
LLMMessage(role=Role.user, content=f"msg-{idx}") for idx in range(66)
|
||||
])
|
||||
|
||||
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway())
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda: len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES,
|
||||
timeout=5.0,
|
||||
)
|
||||
await _wait_for_load_more(app, pilot.pause)
|
||||
|
||||
assert len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES
|
||||
load_more = app.query_one(HistoryLoadMoreMessage)
|
||||
label = load_more.query_one(Button).label
|
||||
assert "(" in str(label)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_session_incremental_loader_load_more_shows_remaining_count(
|
||||
vibe_config: VibeConfig,
|
||||
) -> None:
|
||||
total_messages = 31
|
||||
agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False)
|
||||
agent_loop.messages.extend([
|
||||
LLMMessage(role=Role.user, content=f"msg-{idx}")
|
||||
for idx in range(total_messages)
|
||||
])
|
||||
|
||||
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway())
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda: len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES,
|
||||
timeout=5.0,
|
||||
)
|
||||
await _wait_for_load_more(app, pilot.pause)
|
||||
|
||||
initial_remaining = total_messages - HISTORY_RESUME_TAIL_MESSAGES
|
||||
assert _load_more_remaining(app) == initial_remaining
|
||||
|
||||
app.post_message(HistoryLoadMoreRequested())
|
||||
expected_remaining = initial_remaining - LOAD_MORE_BATCH_SIZE
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda: _load_more_remaining(app) == expected_remaining,
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_session_incremental_loader_load_more_batches_until_done(
|
||||
vibe_config: VibeConfig,
|
||||
) -> None:
|
||||
agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False)
|
||||
agent_loop.messages.extend([
|
||||
LLMMessage(role=Role.user, content=f"msg-{idx}") for idx in range(31)
|
||||
])
|
||||
|
||||
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway())
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda: len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES,
|
||||
timeout=5.0,
|
||||
)
|
||||
await _wait_for_load_more(app, pilot.pause)
|
||||
|
||||
total_messages = 31
|
||||
while len(app.query(HistoryLoadMoreMessage)) == 1:
|
||||
current_count = len(app.query(UserMessage))
|
||||
app.post_message(HistoryLoadMoreRequested())
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda current_count=current_count: len(app.query(UserMessage))
|
||||
> current_count,
|
||||
)
|
||||
|
||||
await _wait_until(
|
||||
pilot.pause, lambda: len(app.query(UserMessage)) == total_messages
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_session_incremental_loader_keeps_top_alignment_when_not_scrollable(
|
||||
vibe_config: VibeConfig,
|
||||
) -> None:
|
||||
agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False)
|
||||
agent_loop.messages.extend([
|
||||
LLMMessage(role=Role.user, content=f"msg-{idx}")
|
||||
for idx in range(HISTORY_RESUME_TAIL_MESSAGES + 1)
|
||||
])
|
||||
|
||||
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway())
|
||||
|
||||
async with app.run_test(size=(120, 80)) as pilot:
|
||||
await _wait_for_load_more(app, pilot.pause)
|
||||
chat = app.query_one("#chat", ChatScroll)
|
||||
assert chat.max_scroll_y == 0
|
||||
assert chat.scroll_y == 0
|
||||
|
|
@ -1,29 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, UserMessage
|
||||
from tests.conftest import (
|
||||
build_test_agent_loop,
|
||||
build_test_vibe_app,
|
||||
build_test_vibe_config,
|
||||
)
|
||||
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||
FakeUpdateCacheRepository,
|
||||
)
|
||||
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||
from vibe.cli.textual_ui.widgets.messages import (
|
||||
AssistantMessage,
|
||||
UserMessage,
|
||||
WhatsNewMessage,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.cli.update_notifier import UpdateCache
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vibe_config() -> VibeConfig:
|
||||
return VibeConfig(
|
||||
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_displays_messages_when_resuming_session(
|
||||
vibe_config: VibeConfig,
|
||||
) -> None:
|
||||
"""Test that messages are properly displayed when resuming a session."""
|
||||
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
|
||||
agent_loop = build_test_agent_loop(config=vibe_config)
|
||||
|
||||
# Simulate a previous session with messages
|
||||
user_msg = LLMMessage(role=Role.user, content="Hello, how are you?")
|
||||
|
|
@ -49,7 +59,7 @@ async def test_ui_displays_messages_when_resuming_session(
|
|||
|
||||
agent_loop.messages.extend([user_msg, assistant_msg, tool_result_msg])
|
||||
|
||||
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
|
||||
app = build_test_vibe_app(agent_loop=agent_loop)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
# Wait for the app to initialize and rebuild history
|
||||
|
|
@ -82,13 +92,13 @@ async def test_ui_does_not_display_messages_when_only_system_messages_exist(
|
|||
vibe_config: VibeConfig,
|
||||
) -> None:
|
||||
"""Test that no messages are displayed when only system messages exist."""
|
||||
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
|
||||
agent_loop = build_test_agent_loop(config=vibe_config)
|
||||
|
||||
# Only system messages
|
||||
system_msg = LLMMessage(role=Role.system, content="System prompt")
|
||||
agent_loop.messages.append(system_msg)
|
||||
|
||||
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
|
||||
app = build_test_vibe_app(agent_loop=agent_loop)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.5)
|
||||
|
|
@ -106,7 +116,7 @@ async def test_ui_displays_multiple_user_assistant_turns(
|
|||
vibe_config: VibeConfig,
|
||||
) -> None:
|
||||
"""Test that multiple conversation turns are properly displayed."""
|
||||
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
|
||||
agent_loop = build_test_agent_loop(config=vibe_config)
|
||||
|
||||
# Multiple conversation turns
|
||||
messages = [
|
||||
|
|
@ -118,7 +128,7 @@ async def test_ui_displays_multiple_user_assistant_turns(
|
|||
|
||||
agent_loop.messages.extend(messages)
|
||||
|
||||
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
|
||||
app = build_test_vibe_app(agent_loop=agent_loop)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.5)
|
||||
|
|
@ -133,3 +143,55 @@ async def test_ui_displays_multiple_user_assistant_turns(
|
|||
assert len(assistant_messages) == 2
|
||||
assert assistant_messages[0]._content == "First answer"
|
||||
assert assistant_messages[1]._content == "Second answer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_rebuilds_history_when_whats_new_is_shown(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
# we have to define an api key to make sure we display the Plan Offer message
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
agent_loop = build_test_agent_loop(config=config)
|
||||
agent_loop.messages.extend([
|
||||
LLMMessage(role=Role.user, content="Hello from the previous session."),
|
||||
LLMMessage(role=Role.assistant, content="Welcome back!"),
|
||||
])
|
||||
update_cache = UpdateCache(
|
||||
latest_version="1.0.0",
|
||||
stored_at_timestamp=int(time.time()),
|
||||
seen_whats_new_version=None,
|
||||
)
|
||||
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
|
||||
plan_offer_gateway = FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
is_pro_plan=False,
|
||||
advertise_pro_plan=True,
|
||||
prompt_switching_to_pro_plan=False,
|
||||
)
|
||||
)
|
||||
app = build_test_vibe_app(
|
||||
agent_loop=agent_loop,
|
||||
update_notifier=FakeUpdateGateway(update=None),
|
||||
update_cache_repository=update_cache_repository,
|
||||
plan_offer_gateway=plan_offer_gateway,
|
||||
current_version="1.0.0",
|
||||
config=config,
|
||||
)
|
||||
|
||||
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
|
||||
whats_new_file = tmp_path / "whats_new.md"
|
||||
whats_new_file.write_text("# What's New\n\n- Feature 1")
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.5)
|
||||
whats_new_message = app.query_one(WhatsNewMessage)
|
||||
message = app.query_one(UserMessage)
|
||||
assistant_message = app.query_one(AssistantMessage)
|
||||
messages_area = app.query_one("#messages")
|
||||
children = list(messages_area.children)
|
||||
|
||||
assert message._content == "Hello from the previous session."
|
||||
assert whats_new_message is not None
|
||||
assert "after-history" in whats_new_message.classes
|
||||
assert children == [message, assistant_message, whats_new_message]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue