"""Tests for clipboard image paste — clipboard extraction, multimodal conversion, and CLI integration. Coverage: hermes_cli/clipboard.py — platform-specific image extraction (macOS, WSL, Wayland, X11) cli.py — _try_attach_clipboard_image, _build_multimodal_content, image attachment state, queue tuple routing """ import base64 import os import queue import subprocess import sys from pathlib import Path from unittest.mock import patch, MagicMock import pytest from hermes_platform.host import runtime as host_runtime from hermes_cli.clipboard import ( has_clipboard_image, _linux_save, _macos_pngpaste, _macos_osascript, _macos_has_image, _xclip_save, _xclip_has_image, _wsl_save, _wsl_has_image, _wayland_save, _wayland_has_image, _windows_save, _windows_has_image, _convert_to_png, _pipe_to_file, _probe, ) from cli import _should_auto_attach_clipboard_image_on_paste FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 FAKE_BMP = b"BM" + b"\x00" * 100 FAKE_JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 100 # ═════════════════════════════════════════════════════════════════════════ # Level 1: Clipboard module — platform dispatch + tool interactions # ═════════════════════════════════════════════════════════════════════════ class TestClipboardChildStdin: def test_probe_uses_devnull_stdin(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) assert _probe(["clipboard-tool"], 3, lambda result: result.returncode == 0) assert mock_run.call_args.kwargs["stdin"] == subprocess.DEVNULL def test_pipe_to_file_uses_devnull_stdin(self, tmp_path): dest = tmp_path / "out.png" def fake_run(_argv, **kwargs): assert kwargs["stdin"] == subprocess.DEVNULL kwargs["stdout"].write(FAKE_PNG) return MagicMock(returncode=0) with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): assert _pipe_to_file(["clipboard-tool"], dest) is True assert dest.read_bytes() == FAKE_PNG # ── macOS ──────────────────────────────────────────────────────────────── 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_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 class TestMacosHasImage: @pytest.mark.parametrize("stdout, expected", [ ("«class PNGf», «class ut16»", True), ("«class ut16», «class utf8»", False), ]) def test_image_class_detection(self, stdout, expected): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=stdout, returncode=0) assert _macos_has_image() is expected class TestMacosOsascript: 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_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 class TestMacosClipboardFileUrl: """Finder / file-copy puts «class furl» on the clipboard, not PNGf/TIFF. Other apps still paste the image; Hermes must treat a local image file-url as a clipboard image too. """ def _furl_run(self, src: Path): def fake_run(cmd, **kw): joined = " ".join(str(part) for part in cmd) if "clipboard info" in joined: return MagicMock(stdout="«class furl», 28", returncode=0) if "«class furl»" in joined: return MagicMock(stdout=f"{src}\n", returncode=0) return MagicMock(stdout="", returncode=1) return fake_run @pytest.mark.parametrize("name, expected", [("shot.png", True), ("notes.txt", False)]) def test_only_copied_image_files_are_clipboard_images(self, tmp_path, name, expected): src = tmp_path / name src.write_bytes(FAKE_PNG) with patch("hermes_cli.clipboard.subprocess.run", side_effect=self._furl_run(src)): assert _macos_has_image() is expected def test_copied_image_file_saves_as_png(self, tmp_path): src = tmp_path / "shot.png" src.write_bytes(FAKE_PNG) dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run", side_effect=self._furl_run(src)): assert _macos_osascript(dest) is True assert dest.read_bytes().startswith(b"\x89PNG") # ── WSL detection ──────────────────────────────────────────────────────── # ── WSL (powershell.exe) ──────────────────────────────────────────────── class TestWslHasImage: @pytest.mark.parametrize("stdout, expected", [ ("True\n", True), ("False\n", False), ]) def test_clipboard_image_probe(self, stdout, expected): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=stdout, returncode=0) assert _wsl_has_image() is expected def test_falls_back_to_get_clipboard_image(self): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.side_effect = [ MagicMock(stdout="False\n", returncode=0), MagicMock(stdout="True\n", returncode=0), ] assert _wsl_has_image() is True assert mock_run.call_count == 2 class TestWslSave: def test_successful_extraction(self, tmp_path): dest = tmp_path / "out.png" b64_png = base64.b64encode(FAKE_PNG).decode() with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=b64_png + "\n", returncode=0) assert _wsl_save(dest) is True assert dest.read_bytes() == FAKE_PNG def test_invalid_base64(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="not-valid-base64!!!", returncode=0) assert _wsl_save(dest) is False # ── Wayland (wl-paste) ────────────────────────────────────────────────── class TestWaylandHasImage: @pytest.mark.parametrize("types, expected", [ ("image/png\ntext/plain\n", True), ("text/html\nimage/bmp\n", True), # non-PNG image types count too ("text/plain\ntext/html\n", False), ]) def test_type_list_detection(self, types, expected): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=types, returncode=0) assert _wayland_has_image() is expected class TestWaylandSave: def test_png_extraction(self, tmp_path): dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(kw) if "--list-types" in cmd: return MagicMock(stdout="image/png\ntext/plain\n", returncode=0) # Extract call — write fake data to stdout file 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 _wayland_save(dest) is True assert dest.stat().st_size > 0 assert all(kwargs["stdin"] == subprocess.DEVNULL for kwargs in calls) def test_prefers_png_over_bmp(self, tmp_path): """When both PNG and BMP are available, PNG should be preferred.""" dest = tmp_path / "out.png" calls = [] def fake_run(cmd, **kw): calls.append(cmd) if "--list-types" in cmd: return MagicMock( stdout="image/bmp\nimage/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 _wayland_save(dest) is True # Verify PNG was requested, not BMP extract_cmd = calls[1] assert "image/png" in extract_cmd # ── X11 (xclip) ───────────────────────────────────────────────────────── class TestXclipHasImage: @pytest.mark.parametrize("targets, expected", [ ("image/png\ntext/plain\n", True), ("text/plain\n", False), ]) def test_targets_detection(self, targets, expected): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=targets, returncode=0) assert _xclip_has_image() is expected class TestXclipSave: 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 _xclip_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 _xclip_save(dest) is False assert not dest.exists() # ── Linux dispatch ────────────────────────────────────────────────────── class TestLinuxSave: """Test that _linux_save dispatches correctly to WSL → Wayland → X11.""" def setup_method(self): host_runtime._wsl_detected = None def test_wsl_tried_first(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard._is_wsl", return_value=True): with patch("hermes_cli.clipboard._wsl_save", return_value=True) as m: assert _linux_save(dest) is True m.assert_called_once_with(dest) def test_wayland_fails_falls_through_to_xclip(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard._is_wsl", return_value=False): with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}): with patch("hermes_cli.clipboard._wayland_save", return_value=False): with patch("hermes_cli.clipboard._xclip_save", return_value=True) as m: assert _linux_save(dest) is True m.assert_called_once_with(dest) # ── Native Windows (PowerShell) ───────────────────────────────────────── class TestWindowsHasImage: def setup_method(self): import hermes_cli.clipboard as cb cb._ps_exe = False # reset cache def test_clipboard_has_image(self): with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout="True\n", returncode=0) assert _windows_has_image() is True def test_falls_back_to_get_clipboard_image(self): with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.side_effect = [ MagicMock(stdout="False\n", returncode=0), MagicMock(stdout="True\n", returncode=0), ] assert _windows_has_image() is True assert mock_run.call_count == 2 class TestWindowsSave: def setup_method(self): import hermes_cli.clipboard as cb cb._ps_exe = False # reset cache def test_successful_extraction(self, tmp_path): dest = tmp_path / "out.png" b64_png = base64.b64encode(FAKE_PNG).decode() with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stdout=b64_png + "\n", returncode=0) assert _windows_save(dest) is True assert dest.read_bytes() == FAKE_PNG def test_falls_back_to_filedrop_image(self, tmp_path): dest = tmp_path / "out.png" b64_png = base64.b64encode(FAKE_PNG).decode() with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): with patch("hermes_cli.clipboard.subprocess.run") as mock_run: mock_run.side_effect = [ MagicMock(stdout="", returncode=1), MagicMock(stdout="", returncode=1), MagicMock(stdout=b64_png + "\n", returncode=0), ] assert _windows_save(dest) is True assert mock_run.call_count == 3 assert dest.read_bytes() == FAKE_PNG # ── BMP conversion ────────────────────────────────────────────────────── class TestConvertToPng: @pytest.mark.parametrize("failure", ["nonzero-exit", "timeout"]) def test_imagemagick_failure_preserves_original(self, tmp_path, failure): """When ImageMagick can't convert, the original file must not be lost.""" dest = tmp_path / "img.png" dest.write_bytes(FAKE_BMP) side_effect = ( (lambda cmd, **kw: MagicMock(returncode=1)) if failure == "nonzero-exit" else subprocess.TimeoutExpired("convert", 5) ) with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}): with patch("hermes_cli.clipboard.subprocess.run", side_effect=side_effect): _convert_to_png(dest) # Original file must still exist with original content assert dest.exists(), "Original file was lost after failed conversion" assert dest.read_bytes() == FAKE_BMP # ── has_clipboard_image dispatch ───────────────────────────────────────── class TestHasClipboardImage: def setup_method(self): host_runtime._wsl_detected = None @pytest.mark.platforms("macos") def test_macos_dispatch(self): """Faking darwin selected the branch but left `_macos_has_image`'s real facility (osascript) absent — only a real macOS host has it.""" with patch("hermes_cli.clipboard._macos_has_image", return_value=True) as m: assert has_clipboard_image() is True m.assert_called_once() @pytest.mark.platforms("linux") def test_wsl_falls_through_to_wayland_when_windows_path_empty(self): """WSLg often bridges images to wl-paste even when powershell.exe check fails. WSL is Linux, so the host reaches the fallthrough on its own; only the WSL/Wayland environment probes below are stubbed. """ with patch("hermes_cli.clipboard._is_wsl", return_value=True): with patch("hermes_cli.clipboard._wsl_has_image", return_value=False) as wsl: with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}): with patch("hermes_cli.clipboard._wayland_has_image", return_value=True) as wl: assert has_clipboard_image() is True wsl.assert_called_once() wl.assert_called_once() # ═════════════════════════════════════════════════════════════════════════ # Level 2: _preprocess_images_with_vision — image → text via vision tool # ═════════════════════════════════════════════════════════════════════════ class TestPreprocessImagesWithVision: """Test vision-based image pre-processing for the CLI.""" @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 _mock_vision_success(self, description="A test image with colored pixels."): """Return an async mock that simulates a successful vision_analyze_tool call.""" import json async def _fake_vision(**kwargs): return json.dumps({"success": True, "analysis": description}) return _fake_vision def test_single_image_with_text(self, cli, tmp_path): img = self._make_image(tmp_path) with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_success()): result = cli._preprocess_images_with_vision("Describe this", [img]) assert isinstance(result, str) assert "A test image with colored pixels." in result assert "Describe this" in result assert str(img) in result assert "base64," not in result # no raw base64 image content def test_vision_exception_includes_path(self, cli, tmp_path): img = self._make_image(tmp_path) async def _explode(**kwargs): raise RuntimeError("API down") with patch("tools.vision_tools.vision_analyze_tool", side_effect=_explode): result = cli._preprocess_images_with_vision("check this", [img]) assert isinstance(result, str) assert str(img) in result # path still included for retry # ═════════════════════════════════════════════════════════════════════════ # 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_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(os.environ["HERMES_HOME"]) / "images" assert path.name.startswith("clip_") assert path.suffix == ".png" class TestAutoAttachClipboardImageOnPaste: @pytest.mark.parametrize("pasted, expected", [ (" hello world ", False), # real text paste — don't hijack it (" \n\t ", True), # whitespace-only paste may be an image ]) def test_auto_attach_decision(self, pasted, expected): assert _should_auto_attach_clipboard_image_on_paste(pasted) is expected class TestVoiceSubmission: @pytest.fixture def cli(self): from cli import HermesCLI cli_obj = HermesCLI.__new__(HermesCLI) cli_obj._attached_images = [Path("/tmp/stale.png")] cli_obj._pending_input = queue.Queue() cli_obj._voice_lock = MagicMock() cli_obj._voice_processing = True cli_obj._voice_recording = True cli_obj._voice_continuous = False cli_obj._no_speech_count = 0 cli_obj._voice_recorder = MagicMock() cli_obj._voice_recorder.stop.return_value = "/tmp/fake.wav" cli_obj._app = None return cli_obj def test_voice_transcript_clears_stale_attached_images(self, cli): with patch("tools.voice_mode.play_beep"): with patch("tools.voice_mode.transcribe_recording", return_value={"success": True, "transcript": "hello"}): with patch("os.path.isfile", return_value=False): with patch("cli._cprint"): cli._voice_stop_and_transcribe() assert cli._attached_images == [] queued = cli._pending_input.get_nowait() # Voice transcripts are wrapped in the _VoiceInputMessage sentinel # (#65827) so process_loop can distinguish STT output from typed text. from cli import _VoiceInputMessage assert isinstance(queued, _VoiceInputMessage) assert queued.text == "hello"