v2.9.0 (#641)
Co-authored-by: Antoine <33425718+anth2o@users.noreply.github.com> Co-authored-by: Bastien <bastien.baret@gmail.com> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@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: Robin Gullo <robin.gullo@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
a83c81ecf5
commit
632ea8c032
253 changed files with 13965 additions and 2525 deletions
|
|
@ -8,6 +8,7 @@ import pytest
|
|||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from vibe.cli.plan_offer.decide_plan_offer import (
|
||||
PlanInfo,
|
||||
WhoAmIPlanType,
|
||||
decide_plan_offer,
|
||||
resolve_api_key_for_plan,
|
||||
|
|
@ -171,3 +172,52 @@ def test_resolve_api_key_for_plan_with_missing_env_var() -> None:
|
|||
|
||||
if previous_api_key is not None:
|
||||
environ["MISTRAL_API_KEY"] = previous_api_key
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("response", "expected"),
|
||||
[
|
||||
(
|
||||
WhoAmIResponse(
|
||||
plan_type=WhoAmIPlanType.CHAT,
|
||||
plan_name="INDIVIDUAL",
|
||||
prompt_switching_to_pro_plan=False,
|
||||
),
|
||||
True,
|
||||
),
|
||||
(
|
||||
WhoAmIResponse(
|
||||
plan_type=WhoAmIPlanType.CHAT,
|
||||
plan_name="INDIVIDUAL",
|
||||
prompt_switching_to_pro_plan=True,
|
||||
),
|
||||
False,
|
||||
),
|
||||
(
|
||||
WhoAmIResponse(
|
||||
plan_type=WhoAmIPlanType.API,
|
||||
plan_name="FREE",
|
||||
prompt_switching_to_pro_plan=False,
|
||||
),
|
||||
False,
|
||||
),
|
||||
(
|
||||
WhoAmIResponse(
|
||||
plan_type=WhoAmIPlanType.MISTRAL_CODE,
|
||||
plan_name="E",
|
||||
prompt_switching_to_pro_plan=False,
|
||||
),
|
||||
False,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"chat-plan-is-eligible",
|
||||
"chat-plan-requiring-key-switch-is-ineligible",
|
||||
"api-plan-is-ineligible",
|
||||
"mistral-code-enterprise-is-ineligible",
|
||||
],
|
||||
)
|
||||
def test_teleport_eligibility_depends_on_chat_plan_and_current_key(
|
||||
response: WhoAmIResponse, expected: bool
|
||||
) -> None:
|
||||
assert PlanInfo.from_response(response).is_teleport_eligible() is expected
|
||||
|
|
|
|||
73
tests/cli/test_cache.py
Normal file
73
tests/cli/test_cache.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
from vibe.cli.cache import read_cache, write_cache
|
||||
|
||||
|
||||
class TestReadCache:
|
||||
def test_reads_valid_toml(self, tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / "cache.toml"
|
||||
cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n')
|
||||
|
||||
result = read_cache(cache_path)
|
||||
|
||||
assert result["update_cache"]["latest_version"] == "1.0.0"
|
||||
|
||||
def test_returns_empty_dict_when_missing(self, tmp_path: Path) -> None:
|
||||
assert read_cache(tmp_path / "missing.toml") == {}
|
||||
|
||||
def test_returns_empty_dict_when_corrupted(self, tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / "cache.toml"
|
||||
cache_path.write_text("{bad toml")
|
||||
|
||||
assert read_cache(cache_path) == {}
|
||||
|
||||
|
||||
class TestWriteCache:
|
||||
def test_writes_new_file(self, tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / "cache.toml"
|
||||
|
||||
write_cache(cache_path, "feedback", {"last_shown_at": 100.0})
|
||||
|
||||
with cache_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data["feedback"]["last_shown_at"] == 100.0
|
||||
|
||||
def test_merges_with_existing(self, tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / "cache.toml"
|
||||
cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n')
|
||||
|
||||
write_cache(cache_path, "feedback", {"last_shown_at": 200.0})
|
||||
|
||||
with cache_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data["update_cache"]["latest_version"] == "1.0.0"
|
||||
assert data["feedback"]["last_shown_at"] == 200.0
|
||||
|
||||
def test_merges_within_section_and_leaves_other_sections_alone(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
cache_path = tmp_path / "cache.toml"
|
||||
cache_path.write_text(
|
||||
"[update_cache]\n"
|
||||
'latest_version = "1.0.0"\n'
|
||||
"stored_at_timestamp = 1\n"
|
||||
'seen_whats_new_version = "1.0.0"\n\n'
|
||||
"[feedback]\n"
|
||||
"last_shown_at = 100.0\n"
|
||||
)
|
||||
|
||||
write_cache(
|
||||
cache_path,
|
||||
"update_cache",
|
||||
{"latest_version": "2.0.0", "stored_at_timestamp": 2},
|
||||
)
|
||||
|
||||
with cache_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data["update_cache"]["latest_version"] == "2.0.0"
|
||||
assert data["update_cache"]["stored_at_timestamp"] == 2
|
||||
assert data["update_cache"]["seen_whats_new_version"] == "1.0.0"
|
||||
assert data["feedback"]["last_shown_at"] == 100.0
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
|
@ -16,6 +17,7 @@ from vibe.cli.clipboard import (
|
|||
_copy_xclip,
|
||||
_read_clipboard,
|
||||
copy_selection_to_clipboard,
|
||||
copy_text_to_clipboard,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -174,6 +176,44 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
|
|||
)
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard._copy_to_clipboard")
|
||||
def test_copy_text_to_clipboard_success(
|
||||
mock_copy_to_clipboard: MagicMock, mock_app: MagicMock
|
||||
) -> None:
|
||||
result = copy_text_to_clipboard(
|
||||
mock_app, "assistant text", success_message="Agent message copied"
|
||||
)
|
||||
|
||||
assert result == "assistant text"
|
||||
mock_copy_to_clipboard.assert_called_once_with("assistant text")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
"Agent message copied", severity="information", timeout=2, markup=False
|
||||
)
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard._copy_to_clipboard")
|
||||
def test_copy_text_to_clipboard_shows_failure_when_clipboard_unavailable(
|
||||
mock_copy_to_clipboard: MagicMock, mock_app: MagicMock
|
||||
) -> None:
|
||||
mock_copy_to_clipboard.side_effect = RuntimeError("All clipboard strategies failed")
|
||||
|
||||
result = copy_text_to_clipboard(mock_app, "assistant text")
|
||||
|
||||
assert result is None
|
||||
mock_copy_to_clipboard.assert_called_once_with("assistant text")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
"Failed to copy - clipboard not available", severity="warning", timeout=3
|
||||
)
|
||||
|
||||
|
||||
def test_copy_text_to_clipboard_returns_none_for_empty_text(
|
||||
mock_app: MagicMock,
|
||||
) -> None:
|
||||
result = copy_text_to_clipboard(mock_app, "")
|
||||
assert result is None
|
||||
mock_app.notify.assert_not_called()
|
||||
|
||||
|
||||
def test_copy_to_clipboard_stops_after_verified_copy() -> None:
|
||||
"""Stops iterating once _read_clipboard confirms the text landed."""
|
||||
mock_first = MagicMock()
|
||||
|
|
@ -244,21 +284,28 @@ def test_read_clipboard_skips_failing_reader() -> None:
|
|||
@patch("subprocess.run")
|
||||
def test_copy_pbcopy(mock_run: MagicMock) -> None:
|
||||
_copy_pbcopy("hello")
|
||||
mock_run.assert_called_once_with(["pbcopy"], input=b"hello", check=True)
|
||||
mock_run.assert_called_once_with(
|
||||
["pbcopy"], input=b"hello", check=True, stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_copy_xclip(mock_run: MagicMock) -> None:
|
||||
_copy_xclip("hello")
|
||||
mock_run.assert_called_once_with(
|
||||
["xclip", "-selection", "clipboard"], input=b"hello", check=True
|
||||
["xclip", "-selection", "clipboard"],
|
||||
input=b"hello",
|
||||
check=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_copy_wl_copy(mock_run: MagicMock) -> None:
|
||||
_copy_wl_copy("hello")
|
||||
mock_run.assert_called_once_with(["wl-copy"], input=b"hello", check=True)
|
||||
mock_run.assert_called_once_with(
|
||||
["wl-copy"], input=b"hello", check=True, stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
|
||||
def test_copy_methods_includes_available_commands() -> None:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.commands import Command, CommandRegistry
|
||||
from vibe.cli.commands import Command, CommandAvailabilityContext, CommandRegistry
|
||||
from vibe.cli.plan_offer.decide_plan_offer import PlanInfo
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType
|
||||
|
||||
|
||||
def _eligible_teleport_context() -> CommandAvailabilityContext:
|
||||
return CommandAvailabilityContext(
|
||||
vibe_code_enabled=True,
|
||||
is_active_model_mistral=True,
|
||||
plan_info=PlanInfo(
|
||||
plan_type=WhoAmIPlanType.CHAT,
|
||||
plan_name="INDIVIDUAL",
|
||||
prompt_switching_to_pro_plan=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestCommandRegistry:
|
||||
|
|
@ -59,6 +73,26 @@ class TestCommandRegistry:
|
|||
assert registry.parse_command("/exit") is None
|
||||
assert registry.get_command_name("/help") == "help"
|
||||
|
||||
def test_teleport_command_hidden_without_eligible_context(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.get_command_name("/teleport") is None
|
||||
assert registry.parse_command("/teleport") is None
|
||||
|
||||
def test_teleport_command_registration_uses_resolved_context(self) -> None:
|
||||
registry = CommandRegistry(availability_context=_eligible_teleport_context())
|
||||
assert registry.get_command_name("/teleport") == "teleport"
|
||||
assert registry.has_command("teleport")
|
||||
|
||||
def test_teleport_help_text_uses_resolved_context(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert "/teleport" not in registry.get_help_text()
|
||||
|
||||
eligible_registry = CommandRegistry(
|
||||
availability_context=_eligible_teleport_context()
|
||||
)
|
||||
assert eligible_registry.get("teleport") is not None
|
||||
assert "/teleport" in eligible_registry.get_help_text()
|
||||
|
||||
def test_resume_command_registration(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.get_command_name("/resume") == "resume"
|
||||
|
|
|
|||
53
tests/cli/test_copy_command.py
Normal file
53
tests/cli/test_copy_command.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
from vibe.cli.textual_ui.widgets.messages import AssistantMessage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_command_copies_last_assistant_message() -> None:
|
||||
app = build_test_vibe_app()
|
||||
second_reply = "\n```python\nprint('second reply')\n```\n"
|
||||
stripped_second_reply = second_reply.strip()
|
||||
async with app.run_test() as pilot:
|
||||
await app._mount_and_scroll(AssistantMessage("first reply"))
|
||||
await app._mount_and_scroll(AssistantMessage(second_reply))
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.app.copy_text_to_clipboard",
|
||||
return_value=stripped_second_reply,
|
||||
) as mock_copy,
|
||||
patch.object(
|
||||
app.agent_loop.telemetry_client, "send_user_copied_text"
|
||||
) as mock_telemetry,
|
||||
):
|
||||
handled = await app._handle_command("/copy")
|
||||
await pilot.pause()
|
||||
assert handled is True
|
||||
assert stripped_second_reply != second_reply
|
||||
mock_copy.assert_called_once_with(
|
||||
app,
|
||||
stripped_second_reply,
|
||||
success_message="Last agent message copied to clipboard",
|
||||
)
|
||||
mock_telemetry.assert_called_once_with(stripped_second_reply)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_command_warns_when_no_assistant_message() -> None:
|
||||
app = build_test_vibe_app()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
with patch.object(app, "notify") as mock_notify:
|
||||
handled = await app._handle_command("/copy")
|
||||
|
||||
await pilot.pause()
|
||||
|
||||
assert handled is True
|
||||
mock_notify.assert_called_once_with(
|
||||
"No agent message available to copy", severity="warning", timeout=3
|
||||
)
|
||||
|
|
@ -4,13 +4,13 @@ from unittest.mock import patch
|
|||
|
||||
import pytest
|
||||
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ctrl_y_triggers_copy_selection() -> None:
|
||||
"""Test that ctrl+y keybinding triggers copy_selection_to_clipboard."""
|
||||
app = BaseSnapshotTestApp()
|
||||
app = build_test_vibe_app()
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
|
||||
async with app.run_test() as pilot:
|
||||
|
|
@ -21,7 +21,7 @@ async def test_ctrl_y_triggers_copy_selection() -> None:
|
|||
@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()
|
||||
app = build_test_vibe_app()
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
|
||||
async with app.run_test() as pilot:
|
||||
|
|
@ -32,11 +32,7 @@ async def test_ctrl_shift_c_triggers_copy_selection() -> None:
|
|||
@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)
|
||||
app = build_test_vibe_app(config=build_test_vibe_config(autocopy_to_clipboard=True))
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
|
||||
async with app.run_test() as pilot:
|
||||
|
|
@ -47,11 +43,9 @@ async def test_mouse_up_respects_autocopy_config_enabled() -> None:
|
|||
@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)
|
||||
app = build_test_vibe_app(
|
||||
config=build_test_vibe_config(autocopy_to_clipboard=False)
|
||||
)
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
|
||||
async with app.run_test() as pilot:
|
||||
|
|
|
|||
|
|
@ -6,27 +6,21 @@ from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
|
|||
|
||||
|
||||
class TestFeedbackBarState:
|
||||
def test_maybe_show_shows_when_random_below_threshold(self):
|
||||
def test_show_activates(self):
|
||||
bar = FeedbackBar()
|
||||
bar.display = False
|
||||
bar._set_active = MagicMock()
|
||||
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=0
|
||||
):
|
||||
bar.maybe_show()
|
||||
bar.show()
|
||||
|
||||
bar._set_active.assert_called_once_with(True)
|
||||
|
||||
def test_maybe_show_does_not_show_when_random_above_threshold(self):
|
||||
def test_show_skips_when_already_displayed(self):
|
||||
bar = FeedbackBar()
|
||||
bar.display = False
|
||||
bar.display = True
|
||||
bar._set_active = MagicMock()
|
||||
|
||||
with patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=1.0
|
||||
):
|
||||
bar.maybe_show()
|
||||
bar.show()
|
||||
|
||||
bar._set_active.assert_not_called()
|
||||
|
||||
|
|
|
|||
149
tests/cli/test_feedback_bar_manager.py
Normal file
149
tests/cli/test_feedback_bar_manager.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
import tomllib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar_manager import (
|
||||
_CACHE_SECTION,
|
||||
_LAST_SHOWN_KEY,
|
||||
FEEDBACK_COOLDOWN_SECONDS,
|
||||
MIN_USER_MESSAGES_FOR_FEEDBACK,
|
||||
FeedbackBarManager,
|
||||
)
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def _patch_cache_file(tmp_path: Path):
|
||||
from vibe.core.paths._vibe_home import GlobalPath
|
||||
|
||||
return patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.CACHE_FILE",
|
||||
GlobalPath(lambda: tmp_path / "cache.toml"),
|
||||
)
|
||||
|
||||
|
||||
def _patch_probability(value: float):
|
||||
return patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.FEEDBACK_PROBABILITY", value
|
||||
)
|
||||
|
||||
|
||||
def _make_agent_loop(
|
||||
user_message_count: int = MIN_USER_MESSAGES_FOR_FEEDBACK,
|
||||
telemetry_active: bool = True,
|
||||
) -> MagicMock:
|
||||
loop = MagicMock()
|
||||
loop.telemetry_client.is_active.return_value = telemetry_active
|
||||
messages = [
|
||||
LLMMessage(role=Role.user, content=f"msg {i}")
|
||||
for i in range(user_message_count)
|
||||
]
|
||||
loop.messages = messages
|
||||
return loop
|
||||
|
||||
|
||||
class TestShouldShow:
|
||||
def test_shows_when_conditions_met(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=0.0,
|
||||
),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is True
|
||||
|
||||
def test_does_not_show_when_random_misses(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=1.0,
|
||||
),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is False
|
||||
|
||||
def test_does_not_show_within_cooldown(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "cache.toml").write_text(
|
||||
f"[{_CACHE_SECTION}]\n{_LAST_SHOWN_KEY} = {int(time.time()) - 60}\n"
|
||||
)
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=0.0,
|
||||
),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is False
|
||||
|
||||
def test_shows_after_cooldown_expires(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "cache.toml").write_text(
|
||||
f"[{_CACHE_SECTION}]\n{_LAST_SHOWN_KEY} = {int(time.time()) - FEEDBACK_COOLDOWN_SECONDS - 1}\n"
|
||||
)
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=0.0,
|
||||
),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is True
|
||||
|
||||
def test_does_not_show_when_telemetry_inactive(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
with _patch_cache_file(tmp_path), _patch_probability(0.2):
|
||||
assert (
|
||||
manager.should_show(_make_agent_loop(telemetry_active=False)) is False
|
||||
)
|
||||
|
||||
def test_does_not_show_when_too_few_user_messages(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=0.0,
|
||||
),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop(user_message_count=1)) is False
|
||||
|
||||
def test_skips_injected_messages_in_count(self, tmp_path: Path) -> None:
|
||||
loop = _make_agent_loop(user_message_count=0)
|
||||
loop.messages = [
|
||||
LLMMessage(role=Role.user, content="real"),
|
||||
LLMMessage(role=Role.user, content="injected", injected=True),
|
||||
LLMMessage(role=Role.assistant, content="reply"),
|
||||
]
|
||||
manager = FeedbackBarManager()
|
||||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=0.0,
|
||||
),
|
||||
):
|
||||
# Only 1 non-injected user message, below MIN_USER_MESSAGES_FOR_FEEDBACK
|
||||
assert manager.should_show(loop) is False
|
||||
|
||||
|
||||
class TestRecordFeedbackAsked:
|
||||
def test_writes_timestamp_to_cache(self, tmp_path: Path) -> None:
|
||||
manager = FeedbackBarManager()
|
||||
before = int(time.time())
|
||||
with _patch_cache_file(tmp_path):
|
||||
manager.record_feedback_asked()
|
||||
with (tmp_path / "cache.toml").open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data[_CACHE_SECTION][_LAST_SHOWN_KEY] >= before
|
||||
|
|
@ -150,9 +150,7 @@ class TestMCPAppInit:
|
|||
app = MCPApp(mcp_servers=servers, tool_manager=mgr)
|
||||
app._viewing_server = "srv"
|
||||
render_calls: list[str | None] = []
|
||||
app._refresh_view = lambda server_name, *, kind=None: render_calls.append(
|
||||
server_name
|
||||
)
|
||||
app._refresh_view = lambda server_name, **_kw: render_calls.append(server_name)
|
||||
app.action_back()
|
||||
assert render_calls == [None]
|
||||
|
||||
|
|
@ -161,9 +159,7 @@ class TestMCPAppInit:
|
|||
app = MCPApp(mcp_servers=[], tool_manager=mgr)
|
||||
app._viewing_server = None
|
||||
render_calls: list[str | None] = []
|
||||
app._refresh_view = lambda server_name, *, kind=None: render_calls.append(
|
||||
server_name
|
||||
)
|
||||
app._refresh_view = lambda server_name, **_kw: render_calls.append(server_name)
|
||||
app.action_back()
|
||||
assert render_calls == []
|
||||
|
||||
|
|
@ -176,17 +172,13 @@ class TestMCPAppInit:
|
|||
mcp_servers=servers, tool_manager=mgr, refresh_callback=refresh_callback
|
||||
)
|
||||
app._viewing_server = "srv"
|
||||
render_calls: list[tuple[str | None, str | None]] = []
|
||||
app._refresh_view = lambda server_name, *, kind=None: render_calls.append((
|
||||
server_name,
|
||||
kind,
|
||||
))
|
||||
app._set_help_text = MagicMock()
|
||||
app.run_worker = MagicMock()
|
||||
|
||||
await app.action_refresh()
|
||||
|
||||
assert app._status_message == "Refreshing..."
|
||||
assert render_calls == [("srv", None)]
|
||||
assert app._refreshing is True
|
||||
app.run_worker.assert_called_once()
|
||||
|
||||
def test_on_worker_state_changed_updates_after_refresh(self) -> None:
|
||||
|
|
|
|||
194
tests/cli/test_programmatic_setup.py
Normal file
194
tests/cli/test_programmatic_setup.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli import cli as cli_mod, entrypoint as entrypoint_mod
|
||||
from vibe.core.config import MissingAPIKeyError
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
|
||||
def _make_args(**overrides: object) -> argparse.Namespace:
|
||||
base: dict[str, object] = {
|
||||
"initial_prompt": None,
|
||||
"prompt": "hello",
|
||||
"max_turns": None,
|
||||
"max_price": None,
|
||||
"enabled_tools": None,
|
||||
"output": "text",
|
||||
"agent": "default",
|
||||
"setup": False,
|
||||
"workdir": None,
|
||||
"trust": False,
|
||||
"teleport": False,
|
||||
"continue_session": False,
|
||||
"resume": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return argparse.Namespace(**base)
|
||||
|
||||
|
||||
def test_programmatic_mode_does_not_run_onboarding_on_missing_api_key(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
def boom() -> None:
|
||||
raise MissingAPIKeyError("MISTRAL_API_KEY", "mistral")
|
||||
|
||||
monkeypatch.setattr(cli_mod.VibeConfig, "load", staticmethod(boom))
|
||||
|
||||
sentinel: dict[str, bool] = {"called": False}
|
||||
|
||||
def fail_onboarding(*_args: object, **_kwargs: object) -> None:
|
||||
sentinel["called"] = True
|
||||
|
||||
monkeypatch.setattr(cli_mod, "run_onboarding", fail_onboarding)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli_mod.load_config_or_exit(interactive=False)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert sentinel["called"] is False
|
||||
err = capsys.readouterr().err
|
||||
assert "MISTRAL_API_KEY" in err
|
||||
assert "vibe --setup" in err
|
||||
|
||||
|
||||
def test_interactive_mode_still_runs_onboarding_on_missing_api_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Replace VibeConfig.load with a stub that fails the first time.
|
||||
state = {"raised": False}
|
||||
|
||||
def fake_load() -> object:
|
||||
if not state["raised"]:
|
||||
state["raised"] = True
|
||||
raise MissingAPIKeyError("MISTRAL_API_KEY", "mistral")
|
||||
return "config-sentinel"
|
||||
|
||||
monkeypatch.setattr(cli_mod.VibeConfig, "load", staticmethod(fake_load))
|
||||
|
||||
onboarding_called: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
cli_mod, "run_onboarding", lambda *a, **k: onboarding_called.append(True)
|
||||
)
|
||||
|
||||
result = cli_mod.load_config_or_exit(interactive=True)
|
||||
assert onboarding_called == [True]
|
||||
assert result == "config-sentinel"
|
||||
|
||||
|
||||
def test_warn_if_workdir_untrusted_writes_stderr_when_project_config_present(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / "AGENTS.md").write_text("hello", encoding="utf-8")
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
cli_mod.warn_if_workdir_trust_is_unset()
|
||||
|
||||
err = " ".join(capsys.readouterr().err.split())
|
||||
assert "not trusted" in err
|
||||
assert "AGENTS.md" in err
|
||||
assert "--trust" in err
|
||||
|
||||
|
||||
def test_warn_if_workdir_untrusted_silent_when_already_trusted(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / "AGENTS.md").write_text("hello", encoding="utf-8")
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
trusted_folders_manager.add_trusted(project)
|
||||
|
||||
cli_mod.warn_if_workdir_trust_is_unset()
|
||||
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_warn_if_workdir_untrusted_silent_when_no_project_config(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
cli_mod.warn_if_workdir_trust_is_unset()
|
||||
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_trust_flag_trusts_cwd_for_session_only(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
args = _make_args(trust=True, prompt=None)
|
||||
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod, "check_and_resolve_trusted_folder", lambda _cwd: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||
)
|
||||
# Stop main() before it runs the actual CLI.
|
||||
monkeypatch.setattr(
|
||||
"vibe.cli.cli.run_cli", lambda _args: (_ for _ in ()).throw(SystemExit(0))
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
entrypoint_mod.main()
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
assert trusted_folders_manager.is_trusted(project) is True
|
||||
# --trust must NOT persist to trusted_folders.toml.
|
||||
assert trusted_folders_manager._trusted == []
|
||||
assert str(project.resolve()) in trusted_folders_manager._session_trusted
|
||||
|
||||
|
||||
def test_trust_flag_works_in_programmatic_mode(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
args = _make_args(trust=True, prompt="run")
|
||||
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod,
|
||||
"check_and_resolve_trusted_folder",
|
||||
lambda _cwd: pytest.fail("must not prompt in -p mode"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"vibe.cli.cli.run_cli", lambda _args: (_ for _ in ()).throw(SystemExit(0))
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
entrypoint_mod.main()
|
||||
|
||||
assert trusted_folders_manager.is_trusted(project) is True
|
||||
assert trusted_folders_manager._trusted == []
|
||||
|
||||
|
||||
def test_session_trust_does_not_write_to_disk(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
trust_file = tmp_path / "trusted_folders.toml"
|
||||
monkeypatch.setattr(trusted_folders_manager, "_file_path", trust_file)
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
|
||||
trusted_folders_manager.trust_for_session(project)
|
||||
|
||||
assert trusted_folders_manager.is_trusted(project) is True
|
||||
assert not trust_file.exists()
|
||||
73
tests/cli/test_stderr_guard.py
Normal file
73
tests/cli/test_stderr_guard.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli.stderr_guard import stderr_guard
|
||||
|
||||
_FORCE_ACTIVE = patch("vibe.cli.stderr_guard._is_stderr_a_tty", return_value=True)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="fd redirect is Unix-only")
|
||||
class TestStderrGuard:
|
||||
def test_fd2_redirected_to_devnull_inside_guard(self) -> None:
|
||||
with _FORCE_ACTIVE:
|
||||
with stderr_guard():
|
||||
devnull_stat = os.stat(os.devnull)
|
||||
fd2_stat = os.fstat(2)
|
||||
assert fd2_stat.st_rdev == devnull_stat.st_rdev
|
||||
|
||||
def test_fd2_restored_after_guard(self) -> None:
|
||||
stat_before = os.fstat(2)
|
||||
with _FORCE_ACTIVE:
|
||||
with stderr_guard():
|
||||
stat_inside = os.fstat(2)
|
||||
assert stat_inside.st_ino != stat_before.st_ino
|
||||
stat_after = os.fstat(2)
|
||||
assert stat_after.st_ino == stat_before.st_ino
|
||||
|
||||
def test_sys_stderr_restored_after_exit(self) -> None:
|
||||
original_stderr = sys.stderr
|
||||
original_dunder = sys.__stderr__
|
||||
with _FORCE_ACTIVE:
|
||||
with stderr_guard():
|
||||
assert sys.__stderr__ is not original_dunder
|
||||
assert sys.stderr is original_stderr
|
||||
assert sys.__stderr__ is original_dunder
|
||||
|
||||
def test_restores_stderr_when_render_flush_raises_value_error(self) -> None:
|
||||
original_stderr = sys.stderr
|
||||
original_dunder = sys.__stderr__
|
||||
stat_before = os.fstat(2)
|
||||
|
||||
with _FORCE_ACTIVE:
|
||||
with stderr_guard():
|
||||
assert sys.__stderr__ is not None
|
||||
sys.__stderr__.close()
|
||||
|
||||
stat_after = os.fstat(2)
|
||||
assert stat_after.st_ino == stat_before.st_ino
|
||||
assert sys.stderr is original_stderr
|
||||
assert sys.__stderr__ is original_dunder
|
||||
|
||||
def test_render_file_is_writable_inside_guard(self) -> None:
|
||||
with _FORCE_ACTIVE:
|
||||
with stderr_guard():
|
||||
assert sys.__stderr__ is not None
|
||||
assert sys.__stderr__.writable()
|
||||
|
||||
def test_noop_when_stderr_is_not_a_tty(self) -> None:
|
||||
original_stderr = sys.stderr
|
||||
with patch("vibe.cli.stderr_guard._is_stderr_a_tty", return_value=False):
|
||||
with stderr_guard():
|
||||
assert sys.stderr is original_stderr
|
||||
|
||||
def test_native_write_to_fd2_goes_to_devnull(self) -> None:
|
||||
with _FORCE_ACTIVE:
|
||||
with stderr_guard():
|
||||
# Should not raise; bytes vanish into /dev/null.
|
||||
written = os.write(2, b"stray MallocStackLogging message\n")
|
||||
assert written > 0
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -8,7 +8,8 @@ from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
|||
from vibe.cli.textual_ui.app import BottomApp
|
||||
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
|
||||
from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp
|
||||
from vibe.core.config._settings import ModelConfig
|
||||
from vibe.cli.textual_ui.widgets.thinking_picker import ThinkingPickerApp
|
||||
from vibe.core.config._settings import THINKING_LEVELS, ModelConfig
|
||||
|
||||
|
||||
def _make_config_with_models():
|
||||
|
|
@ -60,7 +61,8 @@ async def test_config_toggle_autocopy() -> None:
|
|||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Navigate down to Auto-copy (second item) and toggle
|
||||
# Navigate down to Auto-copy (third item, after Model + Thinking) and toggle
|
||||
await pilot.press("down")
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
|
@ -80,7 +82,8 @@ async def test_config_escape_saves_changes() -> None:
|
|||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Toggle auto-copy
|
||||
# Toggle auto-copy (skip Model + Thinking rows)
|
||||
await pilot.press("down")
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
|
@ -268,13 +271,15 @@ async def test_config_pending_changes_saved_before_model_picker() -> None:
|
|||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Toggle auto-copy (second row)
|
||||
# Toggle auto-copy (third row, after Model + Thinking)
|
||||
await pilot.press("down")
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
# Go back up to model row and open model picker
|
||||
await pilot.press("up")
|
||||
await pilot.press("up")
|
||||
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.3)
|
||||
|
|
@ -282,3 +287,151 @@ async def test_config_pending_changes_saved_before_model_picker() -> None:
|
|||
mock_save.assert_called_once()
|
||||
changes = mock_save.call_args[0][0]
|
||||
assert changes["autocopy_to_clipboard"] is True
|
||||
|
||||
|
||||
# --- /thinking command ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thinking_opens_thinking_picker() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_thinking()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.ThinkingPicker
|
||||
assert len(app.query(ThinkingPickerApp)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thinking_picker_shows_all_levels() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_thinking()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
picker = app.query_one(ThinkingPickerApp)
|
||||
assert picker._thinking_levels == THINKING_LEVELS
|
||||
assert picker._current_thinking == "off"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thinking_picker_escape_returns_to_input() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_thinking()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
assert len(app.query(ThinkingPickerApp)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thinking_picker_select_level() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_thinking()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Navigate down to "low" (second item) and select
|
||||
await pilot.press("down")
|
||||
with patch.object(app, "_reload_config", new=AsyncMock()):
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
assert len(app.query(ThinkingPickerApp)) == 0
|
||||
assert app.config.get_active_model().thinking == "low"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thinking_picker_select_high() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_thinking()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Navigate to "high" (4th item = 3 downs from "off")
|
||||
await pilot.press("down")
|
||||
await pilot.press("down")
|
||||
await pilot.press("down")
|
||||
with patch.object(app, "_reload_config", new=AsyncMock()):
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app.config.get_active_model().thinking == "high"
|
||||
|
||||
|
||||
# --- config -> thinking picker flow ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_thinking_entry_opens_thinking_picker() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Thinking row is the second item (after Model). Navigate down and press enter.
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.3)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.ThinkingPicker
|
||||
assert len(app.query(ThinkingPickerApp)) == 1
|
||||
assert len(app.query(ConfigApp)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_to_thinking_picker_escape_returns_to_input() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Open thinking picker from config
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.3)
|
||||
|
||||
# Escape thinking picker
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
assert len(app.query(ThinkingPickerApp)) == 0
|
||||
assert len(app.query(ConfigApp)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_to_thinking_picker_select_returns_to_input() -> None:
|
||||
app = build_test_vibe_app(config=_make_config_with_models())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_config()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Open thinking picker from config
|
||||
await pilot.press("down")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.3)
|
||||
|
||||
# Select "medium" (3rd item = 2 downs from "off")
|
||||
await pilot.press("down")
|
||||
await pilot.press("down")
|
||||
with patch.object(app, "_reload_config", new=AsyncMock()):
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
assert app.config.get_active_model().thinking == "medium"
|
||||
|
|
|
|||
188
tests/cli/test_ui_teleport_availability.py
Normal file
188
tests/cli/test_ui_teleport_availability.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway:
|
||||
return FakeWhoAmIGateway(
|
||||
WhoAmIResponse(
|
||||
plan_type=WhoAmIPlanType.CHAT,
|
||||
plan_name="INDIVIDUAL",
|
||||
prompt_switching_to_pro_plan=prompt_switching_to_pro_plan,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _vibe_code_enabled_config() -> VibeConfig:
|
||||
return build_test_vibe_config(vibe_code_enabled=True)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_command_visible_for_paid_chat_users() -> 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: app.commands.get_command_name("/teleport") == "teleport",
|
||||
)
|
||||
|
||||
assert app.commands.get_command_name("/teleport") == "teleport"
|
||||
assert "/teleport" in app.commands.get_help_text()
|
||||
input_widget = app.query_one(ChatInputContainer).input_widget
|
||||
assert input_widget is not None
|
||||
assert "&" in input_widget.mode_characters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None:
|
||||
app = build_test_vibe_app(
|
||||
config=_vibe_code_enabled_config(),
|
||||
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
|
||||
)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app.commands.get_command_name("/teleport") is None
|
||||
assert "/teleport" not in app.commands.get_help_text()
|
||||
input_widget = app.query_one(ChatInputContainer).input_widget
|
||||
assert input_widget is not None
|
||||
assert "&" not in input_widget.mode_characters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hidden_teleport_command_falls_through_as_user_text() -> None:
|
||||
app = build_test_vibe_app(
|
||||
config=_vibe_code_enabled_config(),
|
||||
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
|
||||
)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.2)
|
||||
|
||||
app._handle_teleport_command = AsyncMock()
|
||||
app._handle_user_message = AsyncMock()
|
||||
|
||||
await app.on_chat_input_container_submitted(
|
||||
ChatInputContainer.Submitted("/teleport")
|
||||
)
|
||||
|
||||
app._handle_teleport_command.assert_not_awaited()
|
||||
app._handle_user_message.assert_awaited_once_with("/teleport")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hidden_ampersand_teleport_shortcut_falls_through_as_user_text() -> None:
|
||||
app = build_test_vibe_app(
|
||||
config=_vibe_code_enabled_config(),
|
||||
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
|
||||
)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.2)
|
||||
|
||||
app._handle_teleport_command = AsyncMock()
|
||||
app._handle_user_message = AsyncMock()
|
||||
|
||||
await app.on_chat_input_container_submitted(
|
||||
ChatInputContainer.Submitted("&continue")
|
||||
)
|
||||
|
||||
app._handle_teleport_command.assert_not_awaited()
|
||||
app._handle_user_message.assert_awaited_once_with("&continue")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_command_hides_after_switching_to_non_mistral_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "mock-openai-key")
|
||||
config = build_test_vibe_config(
|
||||
vibe_code_enabled=True,
|
||||
providers=[
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
),
|
||||
ProviderConfig(
|
||||
name="openai",
|
||||
api_base="https://api.openai.com/v1",
|
||||
api_key_env_var="OPENAI_API_KEY",
|
||||
backend=Backend.GENERIC,
|
||||
),
|
||||
],
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="mistral-vibe-cli-latest", provider="mistral", alias="devstral"
|
||||
),
|
||||
ModelConfig(name="gpt-4.1", provider="openai", alias="gpt"),
|
||||
],
|
||||
active_model="devstral",
|
||||
)
|
||||
app = build_test_vibe_app(
|
||||
config=config,
|
||||
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False),
|
||||
)
|
||||
non_mistral_config = build_test_vibe_config(
|
||||
vibe_code_enabled=True,
|
||||
providers=config.providers,
|
||||
models=config.models,
|
||||
active_model="gpt",
|
||||
)
|
||||
|
||||
async def fake_reload_with_initial_messages(*, base_config) -> None:
|
||||
app.agent_loop._base_config = base_config
|
||||
app.agent_loop.agent_manager.invalidate_config()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await _wait_until(
|
||||
pilot.pause,
|
||||
lambda: app.commands.get_command_name("/teleport") == "teleport",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.textual_ui.app.VibeConfig.load",
|
||||
return_value=non_mistral_config,
|
||||
),
|
||||
patch.object(
|
||||
app.agent_loop,
|
||||
"reload_with_initial_messages",
|
||||
new=AsyncMock(side_effect=fake_reload_with_initial_messages),
|
||||
),
|
||||
):
|
||||
await app._reload_config()
|
||||
|
||||
await _wait_until(
|
||||
pilot.pause, lambda: app.commands.get_command_name("/teleport") is None
|
||||
)
|
||||
|
||||
assert app.commands.get_command_name("/teleport") is None
|
||||
input_widget = app.query_one(ChatInputContainer).input_widget
|
||||
assert input_widget is not None
|
||||
assert "&" not in input_widget.mode_characters
|
||||
12
tests/cli/textual_ui/test_completion_popup.py
Normal file
12
tests/cli/textual_ui/test_completion_popup.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
|
||||
|
||||
|
||||
def test_rendered_text_length_uses_terminal_cell_width() -> None:
|
||||
# "你" and "🙂" both occupy 2 terminal cells in Rich (+2 for separator).
|
||||
assert CompletionPopup.rendered_text_length("@你", "🙂") == 6
|
||||
|
||||
|
||||
def test_rendered_text_length_keeps_description_separator() -> None:
|
||||
assert CompletionPopup.rendered_text_length("@abc", "def") == 8
|
||||
81
tests/cli/textual_ui/test_event_handler_hooks.py
Normal file
81
tests/cli/textual_ui/test_event_handler_hooks.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
import vibe.cli.textual_ui.handlers.event_handler as event_handler_module
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.core.hooks.models import (
|
||||
HookEndEvent,
|
||||
HookMessageSeverity,
|
||||
HookRunEndEvent,
|
||||
HookRunStartEvent,
|
||||
)
|
||||
|
||||
|
||||
class FakeHookRunContainer:
|
||||
def __init__(self) -> None:
|
||||
self.display = False
|
||||
self.remove = AsyncMock()
|
||||
|
||||
async def add_message(self, _widget: object) -> None:
|
||||
self.display = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_run_end_removes_empty_container(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
created_containers: list[FakeHookRunContainer] = []
|
||||
|
||||
def make_container() -> FakeHookRunContainer:
|
||||
container = FakeHookRunContainer()
|
||||
created_containers.append(container)
|
||||
return container
|
||||
|
||||
monkeypatch.setattr(event_handler_module, "HookRunContainer", make_container)
|
||||
|
||||
mount_callback = AsyncMock()
|
||||
handler = EventHandler(
|
||||
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
||||
)
|
||||
|
||||
await handler.handle_event(HookRunStartEvent())
|
||||
await handler.handle_event(HookRunEndEvent())
|
||||
|
||||
assert len(created_containers) == 1
|
||||
created_containers[0].remove.assert_awaited_once()
|
||||
assert handler._hook_run_container is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_run_end_keeps_container_with_messages(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
created_containers: list[FakeHookRunContainer] = []
|
||||
|
||||
def make_container() -> FakeHookRunContainer:
|
||||
container = FakeHookRunContainer()
|
||||
created_containers.append(container)
|
||||
return container
|
||||
|
||||
monkeypatch.setattr(event_handler_module, "HookRunContainer", make_container)
|
||||
|
||||
mount_callback = AsyncMock()
|
||||
handler = EventHandler(
|
||||
mount_callback=mount_callback, get_tools_collapsed=lambda: False
|
||||
)
|
||||
|
||||
await handler.handle_event(HookRunStartEvent())
|
||||
await handler.handle_event(
|
||||
HookEndEvent(
|
||||
hook_name="post-turn", status=HookMessageSeverity.OK, content="Hook output"
|
||||
)
|
||||
)
|
||||
await handler.handle_event(HookRunEndEvent())
|
||||
|
||||
assert len(created_containers) == 1
|
||||
created_containers[0].remove.assert_not_awaited()
|
||||
assert created_containers[0].display is True
|
||||
assert handler._hook_run_container is None
|
||||
169
tests/cli/textual_ui/test_quit_confirmation.py
Normal file
169
tests/cli/textual_ui/test_quit_confirmation.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.quit_manager import QUIT_CONFIRM_DELAY, QuitManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> VibeApp:
|
||||
return build_test_vibe_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qm() -> QuitManager:
|
||||
mock_app = MagicMock()
|
||||
mock_app.query_one.side_effect = Exception("not mounted")
|
||||
mock_app.set_timer.return_value = MagicMock()
|
||||
return QuitManager(mock_app)
|
||||
|
||||
|
||||
class TestQuitManager:
|
||||
def test_not_confirmed_initially(self, qm: QuitManager) -> None:
|
||||
assert qm.is_confirmed("Ctrl+C") is False
|
||||
assert qm.is_confirmed("Ctrl+D") is False
|
||||
|
||||
def test_confirmed_within_delay(self, qm: QuitManager) -> None:
|
||||
qm.request_confirmation("Ctrl+C")
|
||||
assert qm.is_confirmed("Ctrl+C") is True
|
||||
|
||||
def test_wrong_key_not_confirmed(self, qm: QuitManager) -> None:
|
||||
qm.request_confirmation("Ctrl+C")
|
||||
assert qm.is_confirmed("Ctrl+D") is False
|
||||
|
||||
def test_expired_not_confirmed(self, qm: QuitManager) -> None:
|
||||
qm.request_confirmation("Ctrl+C")
|
||||
qm._confirm_time = time.monotonic() - QUIT_CONFIRM_DELAY - 0.1
|
||||
assert qm.is_confirmed("Ctrl+C") is False
|
||||
|
||||
def test_request_resets_timer_on_key_switch(self, qm: QuitManager) -> None:
|
||||
qm.request_confirmation("Ctrl+C")
|
||||
qm._confirm_time = time.monotonic() - QUIT_CONFIRM_DELAY + 0.05
|
||||
qm.request_confirmation("Ctrl+D")
|
||||
assert qm.is_confirmed("Ctrl+D") is True
|
||||
|
||||
def test_confirm_key_property(self, qm: QuitManager) -> None:
|
||||
assert qm.confirm_key is None
|
||||
qm.request_confirmation("Ctrl+D")
|
||||
assert qm.confirm_key == "Ctrl+D"
|
||||
|
||||
def test_request_schedules_cancel_timer(self, qm: QuitManager) -> None:
|
||||
qm.request_confirmation("Ctrl+D")
|
||||
mock_app = qm._app
|
||||
assert isinstance(mock_app, MagicMock)
|
||||
mock_app.set_timer.assert_called_once_with(
|
||||
QUIT_CONFIRM_DELAY, qm.cancel_confirmation
|
||||
)
|
||||
|
||||
def test_request_stops_previous_timer(self, qm: QuitManager) -> None:
|
||||
qm.request_confirmation("Ctrl+C")
|
||||
first_timer = qm._confirm_timer
|
||||
assert isinstance(first_timer, MagicMock)
|
||||
qm.request_confirmation("Ctrl+D")
|
||||
first_timer.stop.assert_called_once()
|
||||
|
||||
def test_cancel_confirmation_resets_state(self, qm: QuitManager) -> None:
|
||||
qm.request_confirmation("Ctrl+C")
|
||||
qm.cancel_confirmation()
|
||||
assert qm.is_confirmed("Ctrl+C") is False
|
||||
assert qm.confirm_key is None
|
||||
assert qm._confirm_timer is None
|
||||
|
||||
def test_cancel_confirmation_noop_when_idle(self, qm: QuitManager) -> None:
|
||||
qm.cancel_confirmation()
|
||||
assert qm.confirm_key is None
|
||||
|
||||
|
||||
class TestActionInterruptOrQuit:
|
||||
def test_clears_input_when_has_value(self, app: VibeApp) -> None:
|
||||
mock_container = MagicMock()
|
||||
mock_container.value = "some text"
|
||||
with patch.object(app, "_get_chat_input", return_value=mock_container):
|
||||
app.action_interrupt_or_quit()
|
||||
assert mock_container.value == ""
|
||||
|
||||
def test_skips_empty_input(self, app: VibeApp) -> None:
|
||||
mock_container = MagicMock()
|
||||
mock_container.value = ""
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=mock_container),
|
||||
patch.object(app, "_try_interrupt", return_value=False),
|
||||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_interrupt_or_quit()
|
||||
mock_confirm.assert_called_once_with("Ctrl+C")
|
||||
|
||||
def test_quits_on_confirmed(self, app: VibeApp) -> None:
|
||||
app._quit_manager._confirm_time = time.monotonic()
|
||||
app._quit_manager._confirm_key = "Ctrl+C"
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=None),
|
||||
patch.object(app, "_force_quit") as mock_quit,
|
||||
):
|
||||
app.action_interrupt_or_quit()
|
||||
mock_quit.assert_called_once()
|
||||
|
||||
def test_interrupts_before_requesting_confirmation(self, app: VibeApp) -> None:
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=None),
|
||||
patch.object(app, "_try_interrupt", return_value=True) as mock_interrupt,
|
||||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_interrupt_or_quit()
|
||||
mock_interrupt.assert_called_once()
|
||||
mock_confirm.assert_not_called()
|
||||
|
||||
def test_requests_confirmation_when_nothing_to_interrupt(
|
||||
self, app: VibeApp
|
||||
) -> None:
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=None),
|
||||
patch.object(app, "_try_interrupt", return_value=False),
|
||||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_interrupt_or_quit()
|
||||
mock_confirm.assert_called_once_with("Ctrl+C")
|
||||
|
||||
|
||||
class TestActionDeleteRightOrQuit:
|
||||
def test_deletes_right_when_input_has_value(self, app: VibeApp) -> None:
|
||||
mock_input = MagicMock()
|
||||
mock_container = MagicMock()
|
||||
mock_container.value = "some text"
|
||||
mock_container.input_widget = mock_input
|
||||
with patch.object(app, "_get_chat_input", return_value=mock_container):
|
||||
app.action_delete_right_or_quit()
|
||||
mock_input.action_delete_right.assert_called_once()
|
||||
|
||||
def test_skips_empty_input(self, app: VibeApp) -> None:
|
||||
mock_container = MagicMock()
|
||||
mock_container.value = ""
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=mock_container),
|
||||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_delete_right_or_quit()
|
||||
mock_confirm.assert_called_once_with("Ctrl+D")
|
||||
|
||||
def test_quits_on_confirmed(self, app: VibeApp) -> None:
|
||||
app._quit_manager._confirm_time = time.monotonic()
|
||||
app._quit_manager._confirm_key = "Ctrl+D"
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=None),
|
||||
patch.object(app, "_force_quit") as mock_quit,
|
||||
):
|
||||
app.action_delete_right_or_quit()
|
||||
mock_quit.assert_called_once()
|
||||
|
||||
def test_requests_confirmation_when_no_input(self, app: VibeApp) -> None:
|
||||
with (
|
||||
patch.object(app, "_get_chat_input", return_value=None),
|
||||
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
|
||||
):
|
||||
app.action_delete_right_or_quit()
|
||||
mock_confirm.assert_called_once_with("Ctrl+D")
|
||||
Loading…
Add table
Add a link
Reference in a new issue