"""Tests for clipboard image paste — clipboard extraction, multimodal conversion, and CLI integration. Coverage: hermes_cli/clipboard.py — platform-specific image extraction cli.py — _try_attach_clipboard_image, _build_multimodal_content, image attachment state, queue tuple routing """ import base64 import queue import subprocess import sys from pathlib import Path from unittest.mock import patch, MagicMock, PropertyMock import pytest from hermes_cli.clipboard import ( save_clipboard_image, _linux_save, _macos_pngpaste, _macos_osascript, ) FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 # ═════════════════════════════════════════════════════════════════════════ # Level 1: Clipboard module — platform dispatch + tool interactions # ═════════════════════════════════════════════════════════════════════════ class TestSaveClipboardImage: def test_dispatches_to_macos_on_darwin(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "darwin" with patch("hermes_cli.clipboard._macos_save", return_value=False) as m: save_clipboard_image(dest) m.assert_called_once_with(dest) def test_dispatches_to_linux_on_linux(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "linux" with patch("hermes_cli.clipboard._linux_save", return_value=False) as m: save_clipboard_image(dest) m.assert_called_once_with(dest) def test_creates_parent_dirs(self, tmp_path): dest = tmp_path / "deep" / "nested" / "out.png" with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "linux" with patch("hermes_cli.clipboard._linux_save", return_value=False): save_clipboard_image(dest) assert dest.parent.exists() class TestMacosPngpaste: def test_success_writes_file(self, tmp_path): dest = tmp_path / "out.png" def fake_run(cmd, **kw): dest.write_bytes(FAKE_PNG) return MagicMock(returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_pngpaste(dest) is True assert dest.stat().st_size == len(FAKE_PNG) def test_not_installed(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _macos_pngpaste(tmp_path / "out.png") is False def test_no_image_in_clipboard(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1) assert _macos_pngpaste(dest) is False assert not dest.exists() def test_empty_file_rejected(self, tmp_path): dest = tmp_path / "out.png" def fake_run(cmd, **kw): dest.write_bytes(b"") return MagicMock(returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_pngpaste(dest) is False def test_timeout_returns_false(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run", side_effect=subprocess.TimeoutExpired("pngpaste", 3)): assert _macos_pngpaste(dest) is False class TestMacosOsascript: def test_no_image_type_in_clipboard(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock( stdout="«class ut16», «class utf8»", returncode=0 ) assert _macos_osascript(tmp_path / "out.png") is False def test_clipboard_info_fails(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run", side_effect=Exception("fail")): assert _macos_osascript(tmp_path / "out.png") is False def test_success_with_png(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if len(calls) == 1: return MagicMock(stdout="«class PNGf», «class ut16»", returncode=0) dest.write_bytes(FAKE_PNG) return MagicMock(stdout="", returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_osascript(dest) is True assert dest.stat().st_size > 0 def test_success_with_tiff(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if len(calls) == 1: return MagicMock(stdout="«class TIFF»", returncode=0) dest.write_bytes(FAKE_PNG) return MagicMock(stdout="", returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_osascript(dest) is True def test_extraction_returns_fail(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if len(calls) == 1: return MagicMock(stdout="«class PNGf»", returncode=0) return MagicMock(stdout="fail", returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_osascript(dest) is False def test_extraction_writes_empty_file(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if len(calls) == 1: return MagicMock(stdout="«class PNGf»", returncode=0) dest.write_bytes(b"") return MagicMock(stdout="", returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _macos_osascript(dest) is False class TestLinuxSave: def test_no_xclip_installed(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _linux_save(tmp_path / "out.png") is False def test_no_image_in_clipboard(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="text/plain\n", returncode=0) assert _linux_save(tmp_path / "out.png") is False def test_image_extraction_success(self, tmp_path): dest = tmp_path / "out.png" def fake_run(cmd, **kw): if "TARGETS" in cmd: return MagicMock(stdout="image/png\ntext/plain\n", returncode=0) if "stdout" in kw and hasattr(kw["stdout"], "write"): kw["stdout"].write(FAKE_PNG) return MagicMock(returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _linux_save(dest) is True assert dest.stat().st_size > 0 def test_extraction_fails_cleans_up(self, tmp_path): dest = tmp_path / "out.png" def fake_run(cmd, **kw): if "TARGETS" in cmd: return MagicMock(stdout="image/png\n", returncode=0) raise subprocess.SubprocessError("pipe broke") with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _linux_save(dest) is False assert not dest.exists() def test_targets_check_timeout(self, tmp_path): with patch("hermes_cli.clipboard.subprocess.run", side_effect=subprocess.TimeoutExpired("xclip", 3)): assert _linux_save(tmp_path / "out.png") is False # ═════════════════════════════════════════════════════════════════════════ # Level 2: _build_multimodal_content — image → OpenAI vision format # ═════════════════════════════════════════════════════════════════════════ class TestBuildMultimodalContent: """Test the extracted _build_multimodal_content method directly.""" @pytest.fixture def cli(self): """Minimal HermesCLI with mocked internals.""" with patch("cli.load_cli_config") as mock_cfg: mock_cfg.return_value = { "model": {"default": "test/model", "base_url": "http://x", "provider": "auto"}, "terminal": {"timeout": 60}, "browser": {}, "compression": {"enabled": True}, "agent": {"max_turns": 10}, "display": {"compact": True}, "clarify": {}, "code_execution": {}, "delegation": {}, } with patch.dict("os.environ", {"OPENROUTER_API_KEY": "test-key"}): with patch("cli.CLI_CONFIG", mock_cfg.return_value): from cli import HermesCLI cli_obj = HermesCLI.__new__(HermesCLI) # Manually init just enough state cli_obj._attached_images = [] cli_obj._image_counter = 0 return cli_obj def _make_image(self, tmp_path, name="test.png", content=FAKE_PNG): img = tmp_path / name img.write_bytes(content) return img def test_single_image_with_text(self, cli, tmp_path): img = self._make_image(tmp_path) result = cli._build_multimodal_content("Describe this", [img]) assert len(result) == 2 assert result[0] == {"type": "text", "text": "Describe this"} assert result[1]["type"] == "image_url" url = result[1]["image_url"]["url"] assert url.startswith("data:image/png;base64,") # Verify the base64 actually decodes to our image b64_data = url.split(",", 1)[1] assert base64.b64decode(b64_data) == FAKE_PNG def test_multiple_images(self, cli, tmp_path): imgs = [self._make_image(tmp_path, f"img{i}.png") for i in range(3)] result = cli._build_multimodal_content("Compare", imgs) assert len(result) == 4 # 1 text + 3 images assert all(r["type"] == "image_url" for r in result[1:]) def test_empty_text_gets_default_question(self, cli, tmp_path): img = self._make_image(tmp_path) result = cli._build_multimodal_content("", [img]) assert result[0]["text"] == "What do you see in this image?" def test_jpeg_mime_type(self, cli, tmp_path): img = self._make_image(tmp_path, "photo.jpg", b"\xff\xd8\xff\x00" * 20) result = cli._build_multimodal_content("test", [img]) assert "image/jpeg" in result[1]["image_url"]["url"] def test_webp_mime_type(self, cli, tmp_path): img = self._make_image(tmp_path, "img.webp", b"RIFF\x00\x00" * 10) result = cli._build_multimodal_content("test", [img]) assert "image/webp" in result[1]["image_url"]["url"] def test_unknown_extension_defaults_to_png(self, cli, tmp_path): img = self._make_image(tmp_path, "data.bmp", b"\x00" * 50) result = cli._build_multimodal_content("test", [img]) assert "image/png" in result[1]["image_url"]["url"] def test_missing_image_skipped(self, cli, tmp_path): missing = tmp_path / "gone.png" result = cli._build_multimodal_content("test", [missing]) assert len(result) == 1 # only text def test_mix_of_existing_and_missing(self, cli, tmp_path): real = self._make_image(tmp_path, "real.png") missing = tmp_path / "gone.png" result = cli._build_multimodal_content("test", [real, missing]) assert len(result) == 2 # text + 1 real image # ═════════════════════════════════════════════════════════════════════════ # Level 3: _try_attach_clipboard_image — state management # ═════════════════════════════════════════════════════════════════════════ class TestTryAttachClipboardImage: """Test the clipboard → state flow.""" @pytest.fixture def cli(self): from cli import HermesCLI cli_obj = HermesCLI.__new__(HermesCLI) cli_obj._attached_images = [] cli_obj._image_counter = 0 return cli_obj def test_image_found_attaches(self, cli): with patch("hermes_cli.clipboard.save_clipboard_image", return_value=True): result = cli._try_attach_clipboard_image() assert result is True assert len(cli._attached_images) == 1 assert cli._image_counter == 1 def test_no_image_doesnt_attach(self, cli): with patch("hermes_cli.clipboard.save_clipboard_image", return_value=False): result = cli._try_attach_clipboard_image() assert result is False assert len(cli._attached_images) == 0 assert cli._image_counter == 0 # rolled back def test_multiple_attaches_increment_counter(self, cli): with patch("hermes_cli.clipboard.save_clipboard_image", return_value=True): cli._try_attach_clipboard_image() cli._try_attach_clipboard_image() cli._try_attach_clipboard_image() assert len(cli._attached_images) == 3 assert cli._image_counter == 3 def test_mixed_success_and_failure(self, cli): results = [True, False, True] with patch("hermes_cli.clipboard.save_clipboard_image", side_effect=results): cli._try_attach_clipboard_image() cli._try_attach_clipboard_image() cli._try_attach_clipboard_image() assert len(cli._attached_images) == 2 assert cli._image_counter == 2 # 3 attempts, 1 rolled back def test_image_path_follows_naming_convention(self, cli): with patch("hermes_cli.clipboard.save_clipboard_image", return_value=True): cli._try_attach_clipboard_image() path = cli._attached_images[0] assert path.parent == Path.home() / ".hermes" / "images" assert path.name.startswith("clip_") assert path.suffix == ".png" # ═════════════════════════════════════════════════════════════════════════ # Level 4: Queue routing — tuple unpacking in process_loop # ═════════════════════════════════════════════════════════════════════════ class TestQueueRouting: """Test that (text, images) tuples are correctly unpacked and routed.""" def test_plain_string_stays_string(self): """Regular text input has no images.""" user_input = "hello world" submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input assert user_input == "hello world" assert submit_images == [] def test_tuple_unpacks_text_and_images(self, tmp_path): """(text, images) tuple is correctly split.""" img = tmp_path / "test.png" img.write_bytes(FAKE_PNG) user_input = ("describe this", [img]) submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input assert user_input == "describe this" assert len(submit_images) == 1 assert submit_images[0] == img def test_empty_text_with_images(self, tmp_path): """Images without text — text should be empty string.""" img = tmp_path / "test.png" img.write_bytes(FAKE_PNG) user_input = ("", [img]) submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input assert user_input == "" assert len(submit_images) == 1 def test_command_with_images_not_treated_as_command(self): """Text starting with / in a tuple should still be a command.""" user_input = "/help" submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input is_command = isinstance(user_input, str) and user_input.startswith("/") assert is_command is True def test_images_only_not_treated_as_command(self, tmp_path): """Empty text + images should not be treated as a command.""" img = tmp_path / "test.png" img.write_bytes(FAKE_PNG) user_input = ("", [img]) submit_images = [] if isinstance(user_input, tuple): user_input, submit_images = user_input is_command = isinstance(user_input, str) and user_input.startswith("/") assert is_command is False assert len(submit_images) == 1