from __future__ import annotations import base64 import subprocess from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock, mock_open, patch import pytest from textual.app import App from vibe.cli.clipboard import ( _copy_osc52, _copy_pbcopy, _copy_to_clipboard, _copy_wl_copy, _copy_xclip, _read_clipboard, copy_selection_to_clipboard, copy_text_to_clipboard, try_copy_text_to_clipboard, ) class MockWidget: def __init__( self, text_selection: object | None = None, get_selection_result: tuple[str, object] | None = None, get_selection_raises: Exception | None = None, ) -> None: self.text_selection = text_selection self._get_selection_result = get_selection_result self._get_selection_raises = get_selection_raises def get_selection(self, selection: object) -> tuple[str, object]: if self._get_selection_raises: raise self._get_selection_raises if self._get_selection_result is None: return ("", None) return self._get_selection_result class MockWidgetNoScreen: @property def text_selection(self) -> object: raise RuntimeError("node has no screen") @pytest.fixture def mock_app() -> App: app = MagicMock(spec=App) app.query = MagicMock(return_value=[]) app.notify = MagicMock() return cast(App, app) @pytest.mark.parametrize( "widgets,description", [ ([], "no widgets"), ([MockWidget(text_selection=None)], "no selection"), ([MockWidget()], "widget without text_selection attr"), ( [ MockWidget( text_selection=SimpleNamespace(), get_selection_raises=ValueError("Error getting selection"), ) ], "get_selection raises", ), ( [MockWidget(text_selection=SimpleNamespace(), get_selection_result=None)], "empty result", ), ( [ MockWidget( text_selection=SimpleNamespace(), get_selection_result=(" ", None) ) ], "empty text", ), ([MockWidgetNoScreen()], "widget with no screen (text_selection raises)"), ], ) def test_copy_selection_to_clipboard_no_notification( mock_app: MagicMock, widgets: list[MockWidget], description: str ) -> None: if description == "widget without text_selection attr": del widgets[0].text_selection mock_app.query.return_value = widgets result = copy_selection_to_clipboard(mock_app) assert result is None mock_app.notify.assert_not_called() @patch("vibe.cli.clipboard._copy_to_clipboard") def test_copy_selection_skips_detached_widget_and_collects_valid( mock_copy_to_clipboard: MagicMock, mock_app: MagicMock ) -> None: detached = MockWidgetNoScreen() valid = MockWidget( text_selection=SimpleNamespace(), get_selection_result=("valid text", None) ) mock_app.query.return_value = [detached, valid] result = copy_selection_to_clipboard(mock_app) assert result == "valid text" mock_copy_to_clipboard.assert_called_once_with("valid text") @patch("vibe.cli.clipboard._copy_to_clipboard") def test_copy_selection_to_clipboard_success( mock_copy_to_clipboard: MagicMock, mock_app: MagicMock ) -> None: widget = MockWidget( text_selection=SimpleNamespace(), get_selection_result=("selected text", None) ) mock_app.query.return_value = [widget] result = copy_selection_to_clipboard(mock_app) assert result == "selected text" mock_copy_to_clipboard.assert_called_once_with("selected text") mock_app.notify.assert_called_once_with( "Selection copied to clipboard", severity="information", timeout=2, markup=False ) @patch("vibe.cli.clipboard._copy_to_clipboard") def test_copy_selection_to_clipboard_shows_failure_when_all_strategies_raise( mock_copy_to_clipboard: MagicMock, mock_app: MagicMock ) -> None: """When _copy_to_clipboard raises (all strategies failed), user sees 'Failed to copy' toast.""" widget = MockWidget( text_selection=SimpleNamespace(), get_selection_result=("selected text", None) ) mock_app.query.return_value = [widget] mock_copy_to_clipboard.side_effect = RuntimeError("All clipboard strategies failed") result = copy_selection_to_clipboard(mock_app) assert result is None mock_copy_to_clipboard.assert_called_once_with("selected text") mock_app.notify.assert_called_once_with( "Failed to copy - clipboard not available", severity="warning", timeout=3 ) def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> None: widget1 = MockWidget( text_selection=SimpleNamespace(), get_selection_result=("first selection", None) ) widget2 = MockWidget( text_selection=SimpleNamespace(), get_selection_result=("second selection", None), ) widget3 = MockWidget(text_selection=None) mock_app.query.return_value = [widget1, widget2, widget3] with patch("vibe.cli.clipboard._copy_to_clipboard") as mock_copy_to_clipboard: result = copy_selection_to_clipboard(mock_app) assert result == "first selection\nsecond selection" mock_copy_to_clipboard.assert_called_once_with( "first selection\nsecond selection" ) mock_app.notify.assert_called_once_with( "Selection copied to clipboard", severity="information", timeout=2, markup=False, ) @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 ) @patch("vibe.cli.clipboard._copy_to_clipboard") def test_try_copy_text_to_clipboard_returns_true_when_copy_succeeds( mock_copy_to_clipboard: MagicMock, ) -> None: result = try_copy_text_to_clipboard("assistant text") assert result is True mock_copy_to_clipboard.assert_called_once_with("assistant text") @patch("vibe.cli.clipboard._copy_to_clipboard") def test_try_copy_text_to_clipboard_returns_false_when_clipboard_unavailable( mock_copy_to_clipboard: MagicMock, ) -> None: mock_copy_to_clipboard.side_effect = RuntimeError("All clipboard strategies failed") result = try_copy_text_to_clipboard("assistant text") assert result is False mock_copy_to_clipboard.assert_called_once_with("assistant text") def test_try_copy_text_to_clipboard_returns_false_for_empty_text() -> None: assert try_copy_text_to_clipboard("") is False 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() mock_second = MagicMock() with ( patch("vibe.cli.clipboard._COPY_METHODS", [mock_first, mock_second]), patch("vibe.cli.clipboard._read_clipboard", return_value="hello"), ): _copy_to_clipboard("hello") mock_first.assert_called_once_with("hello") mock_second.assert_not_called() def test_copy_to_clipboard_tries_all_when_verify_fails() -> None: """Tries all strategies when _read_clipboard never confirms.""" mock_first = MagicMock() mock_second = MagicMock() with ( patch("vibe.cli.clipboard._COPY_METHODS", [mock_first, mock_second]), patch("vibe.cli.clipboard._read_clipboard", return_value=None), ): _copy_to_clipboard("hello") mock_first.assert_called_once_with("hello") mock_second.assert_called_once_with("hello") def test_copy_to_clipboard_raises_when_all_strategies_raise() -> None: """RuntimeError is raised when every strategy fails.""" mock_osc52 = MagicMock(side_effect=OSError("no tty")) mock_pyperclip = MagicMock(side_effect=RuntimeError("pyperclip unavailable")) with ( patch("vibe.cli.clipboard._COPY_METHODS", [mock_osc52, mock_pyperclip]), pytest.raises(RuntimeError, match="All clipboard strategies failed"), ): _copy_to_clipboard("anything") def test_read_clipboard_returns_first_successful_reader() -> None: mock_reader = MagicMock(return_value="hello") mock_reader2 = MagicMock(side_effect=RuntimeError("no clipboard")) with patch( "vibe.cli.clipboard._READ_CLIPBOARD_METHODS", [mock_reader, mock_reader2] ): assert _read_clipboard() == "hello" mock_reader.assert_called_once() mock_reader2.assert_not_called() def test_read_clipboard_falls_through_on_failure() -> None: failing = MagicMock(side_effect=RuntimeError("no clipboard")) with patch("vibe.cli.clipboard._READ_CLIPBOARD_METHODS", [failing]): assert _read_clipboard() is None def test_read_clipboard_skips_failing_reader() -> None: failing = MagicMock(side_effect=RuntimeError("broken")) working = MagicMock(return_value="hello") with patch("vibe.cli.clipboard._READ_CLIPBOARD_METHODS", [failing, working]): assert _read_clipboard() == "hello" working.assert_called_once() @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, 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, 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, stderr=subprocess.DEVNULL ) def test_copy_methods_includes_available_commands() -> None: """_COPY_METHODS is built at import time using _has_cmd; re-import with mocked shutil.which.""" import importlib import vibe.cli.clipboard as mod with patch( "shutil.which", side_effect=lambda cmd: "/usr/bin/xclip" if cmd == "xclip" else None, ): importlib.reload(mod) assert mod._copy_xclip in mod._COPY_METHODS assert mod._copy_pbcopy not in mod._COPY_METHODS assert mod._copy_wl_copy not in mod._COPY_METHODS importlib.reload(mod) @patch("builtins.open", new_callable=mock_open) def test_copy_osc52_writes_correct_sequence( mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.delenv("TMUX", raising=False) test_text = "hello world" _copy_osc52(test_text) encoded = base64.b64encode(test_text.encode("utf-8")).decode("ascii") expected_seq = f"\033]52;c;{encoded}\a" mock_file.assert_called_once_with("/dev/tty", "w") handle = mock_file() handle.write.assert_called_once_with(expected_seq) handle.flush.assert_called_once() @patch("builtins.open", new_callable=mock_open) def test_copy_osc52_with_tmux( mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("TMUX", "1") test_text = "test text" _copy_osc52(test_text) encoded = base64.b64encode(test_text.encode("utf-8")).decode("ascii") expected_seq = f"\033Ptmux;\033\033]52;c;{encoded}\a\033\\" handle = mock_file() handle.write.assert_called_once_with(expected_seq) @patch("builtins.open", new_callable=mock_open) def test_copy_osc52_unicode( mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.delenv("TMUX", raising=False) test_text = "hello world" _copy_osc52(test_text) 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)