* fix: repair 57 failing CI tests across 14 files
Categories of fixes:
**Test isolation under xdist (-n auto):**
- test_hermes_logging: Strip ALL RotatingFileHandlers before each test
to prevent handlers leaked from other xdist workers from polluting counts
- test_code_execution: Force TERMINAL_ENV=local in setUp — prevents Modal
AuthError when another test leaks TERMINAL_ENV=modal
- test_timezone: Same TERMINAL_ENV fix for execute_code timezone tests
- test_codex_execution_paths: Mock _resolve_turn_agent_config to ensure
model resolution works regardless of xdist worker state
**Matrix adapter tests (nio not installed in CI):**
- Add _make_fake_nio() helper with real response classes for isinstance()
checks in production code
- Replace MagicMock(spec=nio.XxxResponse) with fake_nio instances
- Wrap production method calls with patch.dict('sys.modules', {'nio': ...})
so import nio succeeds in method bodies
- Use try/except instead of pytest.importorskip for nio.crypto imports
(importorskip can be fooled by MagicMock in sys.modules)
- test_matrix_voice: Skip entire file if nio is a mock, not just missing
**Stale test expectations:**
- test_cli_provider_resolution: _prompt_provider_choice now takes **kwargs
(default param added); mock getpass.getpass alongside input
- test_anthropic_oauth_flow: Mock getpass.getpass (code switched from input)
- test_gemini_provider: Mock models.dev + OpenRouter API lookups to test
hardcoded defaults without external API variance
- test_code_execution: Add notify_on_complete to blocked terminal params
- test_setup_openclaw_migration: Mock prompt_choice to select 'Full setup'
(new quick-setup path leads to _require_tty → sys.exit in CI)
- test_skill_manager_tool: Patch get_all_skills_dirs alongside SKILLS_DIR
so _find_skill searches tmp_path, not real ~/.hermes/skills/
**Missing attributes in object.__new__ test runners:**
- test_platform_reconnect: Add session_store to _make_runner()
- test_session_race_guard: Add hooks, _running_agents_ts, session_store,
delivery_router to _make_runner()
**Production bug fix (gateway/run.py):**
- Fix sentinel eviction race: _AGENT_PENDING_SENTINEL was immediately
evicted by the stale-detection logic because sentinels have no
get_activity_summary() method, causing _stale_idle=inf >= timeout.
Guard _should_evict with 'is not _AGENT_PENDING_SENTINEL'.
* fix: address remaining CI failures
- test_setup_openclaw_migration: Also mock _offer_launch_chat (called at
end of both quick and full setup paths)
- test_code_execution: Move TERMINAL_ENV=local to module level to protect
ALL test classes (TestEnvVarFiltering, TestExecuteCodeEdgeCases,
TestInterruptHandling, TestHeadTailTruncation) from xdist env leaks
- test_matrix: Use try/except for nio.crypto imports (importorskip can be
fooled by MagicMock in sys.modules under xdist)
349 lines
12 KiB
Python
349 lines
12 KiB
Python
"""Tests for Matrix voice message support (MSC3245)."""
|
|
import io
|
|
import types
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
# Try importing real nio; skip entire file if not available.
|
|
# A MagicMock in sys.modules (from another test) is not the real package.
|
|
try:
|
|
import nio as _nio_probe
|
|
if not isinstance(_nio_probe, types.ModuleType) or not hasattr(_nio_probe, "__file__"):
|
|
pytest.skip("nio in sys.modules is a mock, not the real package", allow_module_level=True)
|
|
except ImportError:
|
|
pytest.skip("matrix-nio not installed", allow_module_level=True)
|
|
|
|
from gateway.platforms.base import MessageType
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_adapter():
|
|
"""Create a MatrixAdapter with mocked config."""
|
|
from gateway.platforms.matrix import MatrixAdapter
|
|
from gateway.config import PlatformConfig
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="***",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
return adapter
|
|
|
|
|
|
def _make_room(room_id: str = "!test:example.org", member_count: int = 2):
|
|
"""Create a mock Matrix room."""
|
|
room = MagicMock()
|
|
room.room_id = room_id
|
|
room.member_count = member_count
|
|
return room
|
|
|
|
|
|
def _make_audio_event(
|
|
event_id: str = "$audio_event",
|
|
sender: str = "@alice:example.org",
|
|
body: str = "Voice message",
|
|
url: str = "mxc://example.org/abc123",
|
|
is_voice: bool = False,
|
|
mimetype: str = "audio/ogg",
|
|
timestamp: float = 9999999999000, # ms
|
|
):
|
|
"""
|
|
Create a mock RoomMessageAudio event that passes isinstance checks.
|
|
|
|
Args:
|
|
is_voice: If True, adds org.matrix.msc3245.voice field to content
|
|
"""
|
|
import nio
|
|
|
|
# Build the source dict that nio events expose via .source
|
|
content = {
|
|
"msgtype": "m.audio",
|
|
"body": body,
|
|
"url": url,
|
|
"info": {
|
|
"mimetype": mimetype,
|
|
},
|
|
}
|
|
|
|
if is_voice:
|
|
content["org.matrix.msc3245.voice"] = {}
|
|
|
|
# Create a real nio RoomMessageAudio-like object
|
|
# We use MagicMock but configure __class__ to pass isinstance check
|
|
event = MagicMock(spec=nio.RoomMessageAudio)
|
|
event.event_id = event_id
|
|
event.sender = sender
|
|
event.body = body
|
|
event.url = url
|
|
event.server_timestamp = timestamp
|
|
event.source = {
|
|
"type": "m.room.message",
|
|
"content": content,
|
|
}
|
|
# For MIME type extraction - needs to be a dict
|
|
event.content = content
|
|
|
|
return event
|
|
|
|
|
|
def _make_download_response(body: bytes = b"fake audio data"):
|
|
"""Create a mock nio.MemoryDownloadResponse."""
|
|
import nio
|
|
resp = MagicMock()
|
|
resp.body = body
|
|
resp.__class__ = nio.MemoryDownloadResponse
|
|
return resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: MSC3245 Voice Detection (RED -> GREEN)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixVoiceMessageDetection:
|
|
"""Test that MSC3245 voice messages are detected and tagged correctly."""
|
|
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._user_id = "@bot:example.org"
|
|
self.adapter._startup_ts = 0.0
|
|
self.adapter._dm_rooms = {}
|
|
self.adapter._message_handler = AsyncMock()
|
|
# Mock _mxc_to_http to return a fake HTTP URL
|
|
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
|
# Mock client for authenticated download
|
|
self.adapter._client = MagicMock()
|
|
self.adapter._client.download = AsyncMock(return_value=_make_download_response())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_voice_message_has_type_voice(self):
|
|
"""Voice messages (with MSC3245 field) should be MessageType.VOICE."""
|
|
room = _make_room()
|
|
event = _make_audio_event(is_voice=True)
|
|
|
|
# Capture the MessageEvent passed to handle_message
|
|
captured_event = None
|
|
|
|
async def capture(msg_event):
|
|
nonlocal captured_event
|
|
captured_event = msg_event
|
|
|
|
self.adapter.handle_message = capture
|
|
|
|
await self.adapter._on_room_message_media(room, event)
|
|
|
|
assert captured_event is not None, "No event was captured"
|
|
assert captured_event.message_type == MessageType.VOICE, \
|
|
f"Expected MessageType.VOICE, got {captured_event.message_type}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_voice_message_has_local_path(self):
|
|
"""Voice messages should have a local cached path in media_urls."""
|
|
room = _make_room()
|
|
event = _make_audio_event(is_voice=True)
|
|
|
|
captured_event = None
|
|
|
|
async def capture(msg_event):
|
|
nonlocal captured_event
|
|
captured_event = msg_event
|
|
|
|
self.adapter.handle_message = capture
|
|
|
|
await self.adapter._on_room_message_media(room, event)
|
|
|
|
assert captured_event is not None
|
|
assert captured_event.media_urls is not None
|
|
assert len(captured_event.media_urls) > 0
|
|
# Should be a local path, not an HTTP URL
|
|
assert not captured_event.media_urls[0].startswith("http"), \
|
|
f"media_urls should contain local path, got {captured_event.media_urls[0]}"
|
|
self.adapter._client.download.assert_awaited_once_with(mxc=event.url)
|
|
assert captured_event.media_types == ["audio/ogg"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_audio_without_msc3245_stays_audio_type(self):
|
|
"""Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO."""
|
|
room = _make_room()
|
|
event = _make_audio_event(is_voice=False) # NOT a voice message
|
|
|
|
captured_event = None
|
|
|
|
async def capture(msg_event):
|
|
nonlocal captured_event
|
|
captured_event = msg_event
|
|
|
|
self.adapter.handle_message = capture
|
|
|
|
await self.adapter._on_room_message_media(room, event)
|
|
|
|
assert captured_event is not None
|
|
assert captured_event.message_type == MessageType.AUDIO, \
|
|
f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_regular_audio_has_http_url(self):
|
|
"""Regular audio uploads should keep HTTP URL (not cached locally)."""
|
|
room = _make_room()
|
|
event = _make_audio_event(is_voice=False)
|
|
|
|
captured_event = None
|
|
|
|
async def capture(msg_event):
|
|
nonlocal captured_event
|
|
captured_event = msg_event
|
|
|
|
self.adapter.handle_message = capture
|
|
|
|
await self.adapter._on_room_message_media(room, event)
|
|
|
|
assert captured_event is not None
|
|
assert captured_event.media_urls is not None
|
|
# Should be HTTP URL, not local path
|
|
assert captured_event.media_urls[0].startswith("http"), \
|
|
f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}"
|
|
self.adapter._client.download.assert_not_awaited()
|
|
assert captured_event.media_types == ["audio/ogg"]
|
|
|
|
|
|
class TestMatrixVoiceCacheFallback:
|
|
"""Test graceful fallback when voice caching fails."""
|
|
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._user_id = "@bot:example.org"
|
|
self.adapter._startup_ts = 0.0
|
|
self.adapter._dm_rooms = {}
|
|
self.adapter._message_handler = AsyncMock()
|
|
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
|
self.adapter._client = MagicMock()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_voice_cache_failure_falls_back_to_http_url(self):
|
|
"""If caching fails, voice message should still be delivered with HTTP URL."""
|
|
room = _make_room()
|
|
event = _make_audio_event(is_voice=True)
|
|
|
|
# Make download fail
|
|
import nio
|
|
error_resp = MagicMock()
|
|
error_resp.__class__ = nio.DownloadError
|
|
self.adapter._client.download = AsyncMock(return_value=error_resp)
|
|
|
|
captured_event = None
|
|
|
|
async def capture(msg_event):
|
|
nonlocal captured_event
|
|
captured_event = msg_event
|
|
|
|
self.adapter.handle_message = capture
|
|
|
|
await self.adapter._on_room_message_media(room, event)
|
|
|
|
assert captured_event is not None
|
|
assert captured_event.media_urls is not None
|
|
# Should fall back to HTTP URL
|
|
assert captured_event.media_urls[0].startswith("http"), \
|
|
f"Should fall back to HTTP URL on cache failure, got {captured_event.media_urls[0]}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_voice_cache_exception_falls_back_to_http_url(self):
|
|
"""Unexpected download exceptions should also fall back to HTTP URL."""
|
|
room = _make_room()
|
|
event = _make_audio_event(is_voice=True)
|
|
|
|
self.adapter._client.download = AsyncMock(side_effect=RuntimeError("boom"))
|
|
|
|
captured_event = None
|
|
|
|
async def capture(msg_event):
|
|
nonlocal captured_event
|
|
captured_event = msg_event
|
|
|
|
self.adapter.handle_message = capture
|
|
|
|
await self.adapter._on_room_message_media(room, event)
|
|
|
|
assert captured_event is not None
|
|
assert captured_event.media_urls is not None
|
|
assert captured_event.media_urls[0].startswith("http"), \
|
|
f"Should fall back to HTTP URL on exception, got {captured_event.media_urls[0]}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: send_voice includes MSC3245 field (RED -> GREEN)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixSendVoiceMSC3245:
|
|
"""Test that send_voice includes MSC3245 field for native voice rendering."""
|
|
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._user_id = "@bot:example.org"
|
|
# Mock client with successful upload
|
|
self.adapter._client = MagicMock()
|
|
self.upload_call = None
|
|
|
|
async def mock_upload(*args, **kwargs):
|
|
self.upload_call = (args, kwargs)
|
|
import nio
|
|
resp = MagicMock()
|
|
resp.content_uri = "mxc://example.org/uploaded"
|
|
resp.__class__ = nio.UploadResponse
|
|
return resp, None
|
|
|
|
self.adapter._client.upload = mock_upload
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_voice_includes_msc3245_field(self):
|
|
"""send_voice should include org.matrix.msc3245.voice in message content."""
|
|
import tempfile
|
|
import os
|
|
|
|
# Create a temp audio file
|
|
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
|
|
f.write(b"fake audio data")
|
|
temp_path = f.name
|
|
|
|
try:
|
|
# Capture the message content sent to room_send
|
|
sent_content = None
|
|
|
|
async def mock_room_send(room_id, event_type, content):
|
|
nonlocal sent_content
|
|
sent_content = content
|
|
resp = MagicMock()
|
|
resp.event_id = "$sent_event"
|
|
import nio
|
|
resp.__class__ = nio.RoomSendResponse
|
|
return resp
|
|
|
|
self.adapter._client.room_send = mock_room_send
|
|
|
|
await self.adapter.send_voice(
|
|
chat_id="!room:example.org",
|
|
audio_path=temp_path,
|
|
caption="Test voice",
|
|
)
|
|
|
|
assert sent_content is not None, "No message was sent"
|
|
assert "org.matrix.msc3245.voice" in sent_content, \
|
|
f"MSC3245 voice field missing from content: {sent_content.keys()}"
|
|
assert sent_content["msgtype"] == "m.audio"
|
|
assert sent_content["info"]["mimetype"] == "audio/ogg"
|
|
assert self.upload_call is not None, "Expected upload() to be called"
|
|
args, kwargs = self.upload_call
|
|
assert isinstance(args[0], io.BytesIO)
|
|
assert kwargs["content_type"] == "audio/ogg"
|
|
assert kwargs["filename"].endswith(".ogg")
|
|
|
|
finally:
|
|
os.unlink(temp_path)
|