refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
"""Tests for clipboard image paste — clipboard extraction, multimodal conversion,
|
|
|
|
|
and CLI integration.
|
2026-03-05 17:53:58 -08:00
|
|
|
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
Coverage:
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
hermes_cli/clipboard.py — platform-specific image extraction (macOS, WSL, Wayland, X11)
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
cli.py — _try_attach_clipboard_image, _build_multimodal_content,
|
|
|
|
|
image attachment state, queue tuple routing
|
2026-03-05 17:58:06 -08:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import base64
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
import os
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
import queue
|
2026-03-05 17:53:58 -08:00
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
from unittest.mock import patch, MagicMock, PropertyMock, mock_open
|
2026-03-05 17:53:58 -08:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from hermes_cli.clipboard import (
|
|
|
|
|
save_clipboard_image,
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
has_clipboard_image,
|
|
|
|
|
_is_wsl,
|
2026-03-05 17:53:58 -08:00
|
|
|
_linux_save,
|
|
|
|
|
_macos_pngpaste,
|
|
|
|
|
_macos_osascript,
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
_macos_has_image,
|
|
|
|
|
_xclip_save,
|
|
|
|
|
_xclip_has_image,
|
|
|
|
|
_wsl_save,
|
|
|
|
|
_wsl_has_image,
|
|
|
|
|
_wayland_save,
|
|
|
|
|
_wayland_has_image,
|
|
|
|
|
_convert_to_png,
|
2026-03-05 17:53:58 -08:00
|
|
|
)
|
|
|
|
|
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
FAKE_BMP = b"BM" + b"\x00" * 100
|
2026-03-05 17:53:58 -08:00
|
|
|
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# Level 1: Clipboard module — platform dispatch + tool interactions
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════
|
2026-03-05 17:53:58 -08:00
|
|
|
|
2026-03-05 17:58:06 -08:00
|
|
|
class TestSaveClipboardImage:
|
2026-03-05 17:53:58 -08:00
|
|
|
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"
|
2026-03-05 17:58:06 -08:00
|
|
|
with patch("hermes_cli.clipboard._macos_save", return_value=False) as m:
|
2026-03-05 17:53:58 -08:00
|
|
|
save_clipboard_image(dest)
|
2026-03-05 17:58:06 -08:00
|
|
|
m.assert_called_once_with(dest)
|
2026-03-05 17:53:58 -08:00
|
|
|
|
|
|
|
|
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"
|
2026-03-05 17:58:06 -08:00
|
|
|
with patch("hermes_cli.clipboard._linux_save", return_value=False) as m:
|
2026-03-05 17:53:58 -08:00
|
|
|
save_clipboard_image(dest)
|
2026-03-05 17:58:06 -08:00
|
|
|
m.assert_called_once_with(dest)
|
2026-03-05 17:53:58 -08:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
# ── macOS ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-05 17:53:58 -08:00
|
|
|
class TestMacosPngpaste:
|
2026-03-05 17:58:06 -08:00
|
|
|
def test_success_writes_file(self, tmp_path):
|
2026-03-05 17:53:58 -08:00
|
|
|
dest = tmp_path / "out.png"
|
2026-03-05 17:58:06 -08:00
|
|
|
def fake_run(cmd, **kw):
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
dest.write_bytes(FAKE_PNG)
|
2026-03-05 17:58:06 -08:00
|
|
|
return MagicMock(returncode=0)
|
|
|
|
|
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
2026-03-05 17:53:58 -08:00
|
|
|
assert _macos_pngpaste(dest) is True
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
assert dest.stat().st_size == len(FAKE_PNG)
|
2026-03-05 17:53:58 -08:00
|
|
|
|
|
|
|
|
def test_not_installed(self, tmp_path):
|
|
|
|
|
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
2026-03-05 17:58:06 -08:00
|
|
|
assert _macos_pngpaste(tmp_path / "out.png") is False
|
2026-03-05 17:53:58 -08:00
|
|
|
|
|
|
|
|
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
|
2026-03-05 17:58:06 -08:00
|
|
|
assert not dest.exists()
|
|
|
|
|
|
|
|
|
|
def test_empty_file_rejected(self, tmp_path):
|
|
|
|
|
dest = tmp_path / "out.png"
|
|
|
|
|
def fake_run(cmd, **kw):
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
dest.write_bytes(b"")
|
2026-03-05 17:58:06 -08:00
|
|
|
return MagicMock(returncode=0)
|
|
|
|
|
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
|
|
|
|
assert _macos_pngpaste(dest) is False
|
|
|
|
|
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
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
|
2026-03-05 17:58:06 -08:00
|
|
|
|
2026-03-05 17:53:58 -08:00
|
|
|
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-03-05 17:53:58 -08:00
|
|
|
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
|
|
|
|
|
)
|
2026-03-05 17:58:06 -08:00
|
|
|
assert _macos_osascript(tmp_path / "out.png") is False
|
2026-03-05 17:53:58 -08:00
|
|
|
|
2026-03-05 17:58:06 -08:00
|
|
|
def test_clipboard_info_fails(self, tmp_path):
|
2026-03-05 17:53:58 -08:00
|
|
|
with patch("hermes_cli.clipboard.subprocess.run", side_effect=Exception("fail")):
|
2026-03-05 17:58:06 -08:00
|
|
|
assert _macos_osascript(tmp_path / "out.png") is False
|
|
|
|
|
|
|
|
|
|
def test_success_with_png(self, tmp_path):
|
|
|
|
|
dest = tmp_path / "out.png"
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
calls = []
|
2026-03-05 17:58:06 -08:00
|
|
|
def fake_run(cmd, **kw):
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
calls.append(cmd)
|
|
|
|
|
if len(calls) == 1:
|
2026-03-05 17:58:06 -08:00
|
|
|
return MagicMock(stdout="«class PNGf», «class ut16»", returncode=0)
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
dest.write_bytes(FAKE_PNG)
|
|
|
|
|
return MagicMock(stdout="", returncode=0)
|
2026-03-05 17:58:06 -08:00
|
|
|
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"
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
calls = []
|
2026-03-05 17:58:06 -08:00
|
|
|
def fake_run(cmd, **kw):
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
calls.append(cmd)
|
|
|
|
|
if len(calls) == 1:
|
2026-03-05 17:58:06 -08:00
|
|
|
return MagicMock(stdout="«class TIFF»", returncode=0)
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
dest.write_bytes(FAKE_PNG)
|
|
|
|
|
return MagicMock(stdout="", returncode=0)
|
2026-03-05 17:58:06 -08:00
|
|
|
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"
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
calls = []
|
2026-03-05 17:58:06 -08:00
|
|
|
def fake_run(cmd, **kw):
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
calls.append(cmd)
|
|
|
|
|
if len(calls) == 1:
|
2026-03-05 17:58:06 -08:00
|
|
|
return MagicMock(stdout="«class PNGf»", returncode=0)
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
return MagicMock(stdout="fail", returncode=0)
|
2026-03-05 17:58:06 -08:00
|
|
|
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
2026-03-05 17:53:58 -08:00
|
|
|
assert _macos_osascript(dest) is False
|
|
|
|
|
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
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
|
2026-03-05 17:53:58 -08:00
|
|
|
|
2026-03-05 17:58:06 -08:00
|
|
|
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
# ── 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:
|
2026-03-05 17:53:58 -08:00
|
|
|
def test_no_xclip_installed(self, tmp_path):
|
|
|
|
|
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
assert _xclip_save(tmp_path / "out.png") is False
|
2026-03-05 17:53:58 -08:00
|
|
|
|
|
|
|
|
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)
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
assert _xclip_save(tmp_path / "out.png") is False
|
2026-03-05 17:53:58 -08:00
|
|
|
|
2026-03-05 17:58:06 -08:00
|
|
|
def test_image_extraction_success(self, tmp_path):
|
2026-03-05 17:53:58 -08:00
|
|
|
dest = tmp_path / "out.png"
|
2026-03-05 17:58:06 -08:00
|
|
|
def fake_run(cmd, **kw):
|
2026-03-05 17:53:58 -08:00
|
|
|
if "TARGETS" in cmd:
|
|
|
|
|
return MagicMock(stdout="image/png\ntext/plain\n", returncode=0)
|
2026-03-05 17:58:06 -08:00
|
|
|
if "stdout" in kw and hasattr(kw["stdout"], "write"):
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
kw["stdout"].write(FAKE_PNG)
|
2026-03-05 17:53:58 -08:00
|
|
|
return MagicMock(returncode=0)
|
|
|
|
|
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
assert _xclip_save(dest) is True
|
2026-03-05 17:58:06 -08:00
|
|
|
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):
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
assert _xclip_save(dest) is False
|
2026-03-05 17:58:06 -08:00
|
|
|
assert not dest.exists()
|
|
|
|
|
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
def test_targets_check_timeout(self, tmp_path):
|
|
|
|
|
with patch("hermes_cli.clipboard.subprocess.run",
|
|
|
|
|
side_effect=subprocess.TimeoutExpired("xclip", 3)):
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
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
|
2026-03-08 17:22:27 -07:00
|
|
|
# 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
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
|
2026-03-07 19:27:23 +03:00
|
|
|
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
|
|
|
|
|
|
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently
fails on WSL2 (can't access Windows clipboard for images), Wayland
desktops (xclip is X11-only), and VSCode terminal on WSL2.
Clipboard backend changes (hermes_cli/clipboard.py):
- WSL2: detect via /proc/version, use powershell.exe with .NET
System.Windows.Forms.Clipboard to extract images as base64 PNG
- Wayland: use wl-paste with MIME type detection, auto-convert BMP
to PNG for WSLg environments (via Pillow or ImageMagick)
- Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough
- New has_clipboard_image() for lightweight clipboard checks
- Cache WSL detection result per-process
CLI changes (cli.py):
- /paste command: explicit clipboard image check for terminals where
BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm)
- Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends
raw byte instead of triggering bracketed paste
Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch,
BMP conversion, has_clipboard_image, and /paste command.
2026-03-05 20:22:44 -08:00
|
|
|
|
|
|
|
|
# ── 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()
|
2026-03-05 17:58:06 -08:00
|
|
|
|
|
|
|
|
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
# ═════════════════════════════════════════════════════════════════════════
|
2026-03-08 06:21:53 -07:00
|
|
|
# Level 2: _preprocess_images_with_vision — image → text via vision tool
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
# ═════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
2026-03-08 06:21:53 -07:00
|
|
|
class TestPreprocessImagesWithVision:
|
|
|
|
|
"""Test vision-based image pre-processing for the CLI."""
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
|
|
|
|
|
@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):
|
2026-03-05 17:58:06 -08:00
|
|
|
img = tmp_path / name
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
img.write_bytes(content)
|
2026-03-05 17:58:06 -08:00
|
|
|
return img
|
|
|
|
|
|
2026-03-08 06:21:53 -07:00
|
|
|
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
|
|
|
|
|
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
def test_single_image_with_text(self, cli, tmp_path):
|
|
|
|
|
img = self._make_image(tmp_path)
|
2026-03-08 06:21:53 -07:00
|
|
|
with patch("tools.vision_tools.vision_analyze_tool", side_effect=self._mock_vision_success()):
|
|
|
|
|
result = cli._preprocess_images_with_vision("Describe this", [img])
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
|
2026-03-08 06:21:53 -07:00
|
|
|
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
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
|
|
|
|
|
def test_multiple_images(self, cli, tmp_path):
|
|
|
|
|
imgs = [self._make_image(tmp_path, f"img{i}.png") for i in range(3)]
|
2026-03-08 06:21:53 -07:00
|
|
|
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
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
|
|
|
|
|
def test_empty_text_gets_default_question(self, cli, tmp_path):
|
|
|
|
|
img = self._make_image(tmp_path)
|
2026-03-08 06:21:53 -07:00
|
|
|
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
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
|
|
|
|
|
def test_missing_image_skipped(self, cli, tmp_path):
|
|
|
|
|
missing = tmp_path / "gone.png"
|
2026-03-08 06:21:53 -07:00
|
|
|
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"
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
|
|
|
|
|
def test_mix_of_existing_and_missing(self, cli, tmp_path):
|
|
|
|
|
real = self._make_image(tmp_path, "real.png")
|
|
|
|
|
missing = tmp_path / "gone.png"
|
2026-03-08 06:21:53 -07:00
|
|
|
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
|
refactor: extract clipboard methods + comprehensive tests (37 tests)
Refactored image paste internals for testability:
- Extracted _try_attach_clipboard_image() method (clipboard → state)
- Extracted _build_multimodal_content() method (images → OpenAI format)
- chat() now delegates to these instead of inline logic
Tests organized in 4 levels:
Level 1 (19 tests): Clipboard module — every platform path with
realistic subprocess simulation (tools writing files, timeouts,
empty files, cleanup on failure)
Level 2 (8 tests): _build_multimodal_content — base64 encoding,
MIME types (png/jpg/webp/unknown), missing files, multiple images,
default question for empty text
Level 3 (5 tests): _try_attach_clipboard_image — state management,
counter increment/rollback, naming convention, mixed success/failure
Level 4 (5 tests): Queue routing — tuple unpacking, command detection,
images-only payloads, text-only payloads
2026-03-05 18:07:53 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 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
|