The architecture has been updated
This commit is contained in:
parent
805f7a017e
commit
a01257ead9
1119 changed files with 226 additions and 352 deletions
877
hermes_code/tests/tools/test_clipboard.py
Normal file
877
hermes_code/tests/tools/test_clipboard.py
Normal file
|
|
@ -0,0 +1,877 @@
|
|||
"""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, PropertyMock, mock_open
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.clipboard import (
|
||||
save_clipboard_image,
|
||||
has_clipboard_image,
|
||||
_is_wsl,
|
||||
_linux_save,
|
||||
_macos_pngpaste,
|
||||
_macos_osascript,
|
||||
_macos_has_image,
|
||||
_xclip_save,
|
||||
_xclip_has_image,
|
||||
_wsl_save,
|
||||
_wsl_has_image,
|
||||
_wayland_save,
|
||||
_wayland_has_image,
|
||||
_convert_to_png,
|
||||
)
|
||||
|
||||
FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
||||
FAKE_BMP = b"BM" + 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()
|
||||
|
||||
|
||||
# ── 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_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 TestMacosHasImage:
|
||||
def test_png_detected(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="«class PNGf», «class ut16»", returncode=0
|
||||
)
|
||||
assert _macos_has_image() is True
|
||||
|
||||
def test_tiff_detected(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="«class TIFF»", returncode=0
|
||||
)
|
||||
assert _macos_has_image() is True
|
||||
|
||||
def test_text_only(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="«class ut16», «class utf8»", returncode=0
|
||||
)
|
||||
assert _macos_has_image() 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
|
||||
|
||||
|
||||
# ── WSL detection ────────────────────────────────────────────────────────
|
||||
|
||||
class TestIsWsl:
|
||||
def setup_method(self):
|
||||
# Reset cached value before each test
|
||||
import hermes_cli.clipboard as cb
|
||||
cb._wsl_detected = None
|
||||
|
||||
def test_wsl2_detected(self):
|
||||
content = "Linux version 5.15.0 (microsoft-standard-WSL2)"
|
||||
with patch("builtins.open", mock_open(read_data=content)):
|
||||
assert _is_wsl() is True
|
||||
|
||||
def test_wsl1_detected(self):
|
||||
content = "Linux version 4.4.0-microsoft-standard"
|
||||
with patch("builtins.open", mock_open(read_data=content)):
|
||||
assert _is_wsl() is True
|
||||
|
||||
def test_regular_linux(self):
|
||||
content = "Linux version 6.14.0-37-generic (buildd@lcy02-amd64-049)"
|
||||
with patch("builtins.open", mock_open(read_data=content)):
|
||||
assert _is_wsl() is False
|
||||
|
||||
def test_proc_version_missing(self):
|
||||
with patch("builtins.open", side_effect=FileNotFoundError):
|
||||
assert _is_wsl() is False
|
||||
|
||||
def test_result_is_cached(self):
|
||||
content = "Linux version 5.15.0 (microsoft-standard-WSL2)"
|
||||
with patch("builtins.open", mock_open(read_data=content)) as m:
|
||||
assert _is_wsl() is True
|
||||
assert _is_wsl() is True
|
||||
m.assert_called_once() # only read once
|
||||
|
||||
|
||||
# ── WSL (powershell.exe) ────────────────────────────────────────────────
|
||||
|
||||
class TestWslHasImage:
|
||||
def test_clipboard_has_image(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="True\n", returncode=0)
|
||||
assert _wsl_has_image() is True
|
||||
|
||||
def test_clipboard_no_image(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="False\n", returncode=0)
|
||||
assert _wsl_has_image() is False
|
||||
|
||||
def test_powershell_not_found(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _wsl_has_image() is False
|
||||
|
||||
def test_powershell_error(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=1)
|
||||
assert _wsl_has_image() is False
|
||||
|
||||
|
||||
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_no_image_returns_false(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=1)
|
||||
assert _wsl_save(dest) is False
|
||||
assert not dest.exists()
|
||||
|
||||
def test_empty_output(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=0)
|
||||
assert _wsl_save(dest) is False
|
||||
|
||||
def test_powershell_not_found(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _wsl_save(dest) is False
|
||||
|
||||
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
|
||||
|
||||
def test_timeout(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("powershell.exe", 15)):
|
||||
assert _wsl_save(dest) is False
|
||||
|
||||
|
||||
# ── Wayland (wl-paste) ──────────────────────────────────────────────────
|
||||
|
||||
class TestWaylandHasImage:
|
||||
def test_has_png(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="image/png\ntext/plain\n", returncode=0
|
||||
)
|
||||
assert _wayland_has_image() is True
|
||||
|
||||
def test_has_bmp_only(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="text/html\nimage/bmp\n", returncode=0
|
||||
)
|
||||
assert _wayland_has_image() is True
|
||||
|
||||
def test_text_only(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="text/plain\ntext/html\n", returncode=0
|
||||
)
|
||||
assert _wayland_has_image() is False
|
||||
|
||||
def test_wl_paste_not_installed(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _wayland_has_image() is False
|
||||
|
||||
|
||||
class TestWaylandSave:
|
||||
def test_png_extraction(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
calls = []
|
||||
def fake_run(cmd, **kw):
|
||||
calls.append(cmd)
|
||||
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
|
||||
|
||||
def test_bmp_extraction_with_pillow_convert(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
calls = []
|
||||
def fake_run(cmd, **kw):
|
||||
calls.append(cmd)
|
||||
if "--list-types" in cmd:
|
||||
return MagicMock(stdout="text/html\nimage/bmp\n", returncode=0)
|
||||
if "stdout" in kw and hasattr(kw["stdout"], "write"):
|
||||
kw["stdout"].write(FAKE_BMP)
|
||||
return MagicMock(returncode=0)
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
with patch("hermes_cli.clipboard._convert_to_png", return_value=True):
|
||||
assert _wayland_save(dest) is True
|
||||
|
||||
def test_no_image_types(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="text/plain\ntext/html\n", returncode=0
|
||||
)
|
||||
assert _wayland_save(dest) is False
|
||||
|
||||
def test_wl_paste_not_installed(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _wayland_save(dest) is False
|
||||
|
||||
def test_list_types_fails(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=1)
|
||||
assert _wayland_save(dest) is False
|
||||
|
||||
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:
|
||||
def test_has_image(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="image/png\ntext/plain\n", returncode=0
|
||||
)
|
||||
assert _xclip_has_image() is True
|
||||
|
||||
def test_no_image(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="text/plain\n", returncode=0
|
||||
)
|
||||
assert _xclip_has_image() is False
|
||||
|
||||
def test_xclip_not_installed(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _xclip_has_image() is False
|
||||
|
||||
|
||||
class TestXclipSave:
|
||||
def test_no_xclip_installed(self, tmp_path):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _xclip_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 _xclip_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 _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()
|
||||
|
||||
def test_targets_check_timeout(self, tmp_path):
|
||||
with patch("hermes_cli.clipboard.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("xclip", 3)):
|
||||
assert _xclip_save(tmp_path / "out.png") is False
|
||||
|
||||
|
||||
# ── Linux dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
class TestLinuxSave:
|
||||
"""Test that _linux_save dispatches correctly to WSL → Wayland → X11."""
|
||||
|
||||
def setup_method(self):
|
||||
import hermes_cli.clipboard as cb
|
||||
cb._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_wsl_fails_falls_through_to_xclip(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=False):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch("hermes_cli.clipboard._xclip_save", return_value=True) as m:
|
||||
assert _linux_save(dest) is True
|
||||
m.assert_called_once_with(dest)
|
||||
|
||||
def test_wayland_tried_when_display_set(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=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)
|
||||
|
||||
def test_xclip_used_on_plain_x11(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=False):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch("hermes_cli.clipboard._xclip_save", return_value=True) as m:
|
||||
assert _linux_save(dest) is True
|
||||
m.assert_called_once_with(dest)
|
||||
|
||||
|
||||
# ── BMP conversion ──────────────────────────────────────────────────────
|
||||
|
||||
class TestConvertToPng:
|
||||
def test_pillow_conversion(self, tmp_path):
|
||||
dest = tmp_path / "img.png"
|
||||
dest.write_bytes(FAKE_BMP)
|
||||
mock_img_instance = MagicMock()
|
||||
mock_image_cls = MagicMock()
|
||||
mock_image_cls.open.return_value = mock_img_instance
|
||||
# `from PIL import Image` fetches PIL.Image from the PIL module
|
||||
mock_pil_module = MagicMock()
|
||||
mock_pil_module.Image = mock_image_cls
|
||||
with patch.dict(sys.modules, {"PIL": mock_pil_module}):
|
||||
assert _convert_to_png(dest) is True
|
||||
mock_img_instance.save.assert_called_once_with(dest, "PNG")
|
||||
|
||||
def test_pillow_not_available_tries_imagemagick(self, tmp_path):
|
||||
dest = tmp_path / "img.png"
|
||||
dest.write_bytes(FAKE_BMP)
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
# Simulate ImageMagick converting
|
||||
dest.write_bytes(FAKE_PNG)
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
# Force ImportError for Pillow
|
||||
import hermes_cli.clipboard as cb
|
||||
original = cb._convert_to_png
|
||||
|
||||
def patched_convert(path):
|
||||
# Skip Pillow, go straight to ImageMagick
|
||||
try:
|
||||
tmp = path.with_suffix(".bmp")
|
||||
path.rename(tmp)
|
||||
import subprocess as sp
|
||||
r = sp.run(
|
||||
["convert", str(tmp), "png:" + str(path)],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
tmp.unlink(missing_ok=True)
|
||||
return r.returncode == 0 and path.exists() and path.stat().st_size > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Just test that the fallback logic exists
|
||||
assert dest.exists()
|
||||
|
||||
def test_file_still_usable_when_no_converter(self, tmp_path):
|
||||
"""BMP file should still be reported as success if no converter available."""
|
||||
dest = tmp_path / "img.png"
|
||||
dest.write_bytes(FAKE_BMP) # it's a BMP but named .png
|
||||
# Both Pillow and ImageMagick unavailable
|
||||
with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
result = _convert_to_png(dest)
|
||||
# Raw BMP is better than nothing — function should return True
|
||||
assert result is True
|
||||
assert dest.exists() and dest.stat().st_size > 0
|
||||
|
||||
def test_imagemagick_failure_preserves_original(self, tmp_path):
|
||||
"""When ImageMagick convert fails, the original file must not be lost."""
|
||||
dest = tmp_path / "img.png"
|
||||
original_data = FAKE_BMP
|
||||
dest.write_bytes(original_data)
|
||||
|
||||
def fake_run_fail(cmd, **kw):
|
||||
# Simulate convert failing without producing output
|
||||
return MagicMock(returncode=1)
|
||||
|
||||
with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run_fail):
|
||||
_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() == original_data
|
||||
|
||||
def test_imagemagick_not_installed_preserves_original(self, tmp_path):
|
||||
"""When ImageMagick is not installed, the original file must not be lost."""
|
||||
dest = tmp_path / "img.png"
|
||||
original_data = FAKE_BMP
|
||||
dest.write_bytes(original_data)
|
||||
|
||||
with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
_convert_to_png(dest)
|
||||
|
||||
assert dest.exists(), "Original file was lost when ImageMagick not installed"
|
||||
assert dest.read_bytes() == original_data
|
||||
|
||||
def test_imagemagick_timeout_preserves_original(self, tmp_path):
|
||||
"""When ImageMagick times out, the original file must not be lost."""
|
||||
import subprocess
|
||||
dest = tmp_path / "img.png"
|
||||
original_data = FAKE_BMP
|
||||
dest.write_bytes(original_data)
|
||||
|
||||
with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=subprocess.TimeoutExpired("convert", 5)):
|
||||
_convert_to_png(dest)
|
||||
|
||||
assert dest.exists(), "Original file was lost after timeout"
|
||||
assert dest.read_bytes() == original_data
|
||||
|
||||
|
||||
# ── has_clipboard_image dispatch ─────────────────────────────────────────
|
||||
|
||||
class TestHasClipboardImage:
|
||||
def setup_method(self):
|
||||
import hermes_cli.clipboard as cb
|
||||
cb._wsl_detected = None
|
||||
|
||||
def test_macos_dispatch(self):
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
with patch("hermes_cli.clipboard._macos_has_image", return_value=True) as m:
|
||||
assert has_clipboard_image() is True
|
||||
m.assert_called_once()
|
||||
|
||||
def test_linux_wsl_dispatch(self):
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=True):
|
||||
with patch("hermes_cli.clipboard._wsl_has_image", return_value=True) as m:
|
||||
assert has_clipboard_image() is True
|
||||
m.assert_called_once()
|
||||
|
||||
def test_linux_wayland_dispatch(self):
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
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_has_image", return_value=True) as m:
|
||||
assert has_clipboard_image() is True
|
||||
m.assert_called_once()
|
||||
|
||||
def test_linux_x11_dispatch(self):
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=False):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch("hermes_cli.clipboard._xclip_has_image", return_value=True) as m:
|
||||
assert has_clipboard_image() is True
|
||||
m.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 _mock_vision_failure(self):
|
||||
"""Return an async mock that simulates a failed vision_analyze_tool call."""
|
||||
import json
|
||||
async def _fake_vision(**kwargs):
|
||||
return json.dumps({"success": False, "analysis": "Error"})
|
||||
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_multiple_images(self, cli, tmp_path):
|
||||
imgs = [self._make_image(tmp_path, f"img{i}.png") for i in range(3)]
|
||||
with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_success()):
|
||||
result = cli._preprocess_images_with_vision("Compare", imgs)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "Compare" in result
|
||||
# Each image path should be referenced
|
||||
for img in imgs:
|
||||
assert str(img) in result
|
||||
|
||||
def test_empty_text_gets_default_question(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("", [img])
|
||||
assert isinstance(result, str)
|
||||
assert "A test image with colored pixels." in result
|
||||
|
||||
def test_missing_image_skipped(self, cli, tmp_path):
|
||||
missing = tmp_path / "gone.png"
|
||||
with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_success()):
|
||||
result = cli._preprocess_images_with_vision("test", [missing])
|
||||
# No images analyzed, falls back to default
|
||||
assert result == "test"
|
||||
|
||||
def test_mix_of_existing_and_missing(self, cli, tmp_path):
|
||||
real = self._make_image(tmp_path, "real.png")
|
||||
missing = tmp_path / "gone.png"
|
||||
with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_success()):
|
||||
result = cli._preprocess_images_with_vision("test", [real, missing])
|
||||
assert str(real) in result
|
||||
assert str(missing) not in result
|
||||
assert "test" in result
|
||||
|
||||
def test_vision_failure_includes_path(self, cli, tmp_path):
|
||||
img = self._make_image(tmp_path)
|
||||
with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_failure()):
|
||||
result = cli._preprocess_images_with_vision("check this", [img])
|
||||
assert isinstance(result, str)
|
||||
assert str(img) in result # path still included for retry
|
||||
assert "check this" in result
|
||||
|
||||
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_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(os.environ["HERMES_HOME"]) / "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
|
||||
Loading…
Add table
Add a link
Reference in a new issue