- Nouveau module persistent_buffer.py (SQLite WAL, thread-safe)
- Purge automatique des captures locales après ACK 200 serveur
- Drain loop 15s, retry exponentiel, plafonds tentatives
- Enum ImageSendResult.{OK, FAILED, FILE_GONE} pour distinguer les cas
- FileNotFoundError n'est plus un faux succès (P0-E audit)
- 14 tests intégration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
347 lines
13 KiB
Python
347 lines
13 KiB
Python
"""
|
|
Tests de compatibilité Client (Agent V1) ↔ Serveur (api_stream).
|
|
|
|
Vérifie que les payloads envoyés par le TraceStreamer correspondent
|
|
exactement à ce que l'API serveur attend (formats, champs, endpoints).
|
|
|
|
Sans réseau réel : on mocke requests.post et on valide les appels.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, call, patch
|
|
|
|
import pytest
|
|
|
|
_ROOT = str(Path(__file__).resolve().parents[2])
|
|
if _ROOT not in sys.path:
|
|
sys.path.insert(0, _ROOT)
|
|
|
|
|
|
# =========================================================================
|
|
# TraceStreamer ↔ API endpoints
|
|
# =========================================================================
|
|
|
|
|
|
class TestStreamerEndpoints:
|
|
"""Vérifie que le client appelle les bons endpoints."""
|
|
|
|
def test_register_endpoint(self):
|
|
"""start() appelle POST /register avec session_id."""
|
|
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
|
|
|
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
|
mock_req.post.return_value = MagicMock(ok=True)
|
|
streamer = TraceStreamer("sess_test_001")
|
|
streamer.start()
|
|
streamer.stop()
|
|
|
|
# Trouver l'appel register
|
|
register_calls = [
|
|
c for c in mock_req.post.call_args_list
|
|
if "/register" in str(c)
|
|
]
|
|
assert len(register_calls) >= 1, "register endpoint jamais appelé"
|
|
_, kwargs = register_calls[0]
|
|
assert kwargs["params"]["session_id"] == "sess_test_001"
|
|
|
|
def test_finalize_endpoint(self):
|
|
"""stop() appelle POST /finalize avec session_id."""
|
|
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
|
|
|
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
|
mock_req.post.return_value = MagicMock(ok=True, json=lambda: {"status": "ok"})
|
|
streamer = TraceStreamer("sess_test_002")
|
|
streamer._server_available = True
|
|
streamer.running = False
|
|
streamer._finalize_session()
|
|
|
|
finalize_calls = [
|
|
c for c in mock_req.post.call_args_list
|
|
if "/finalize" in str(c)
|
|
]
|
|
assert len(finalize_calls) >= 1, "finalize endpoint jamais appelé"
|
|
_, kwargs = finalize_calls[0]
|
|
assert kwargs["params"]["session_id"] == "sess_test_002"
|
|
|
|
|
|
# =========================================================================
|
|
# Payload formats
|
|
# =========================================================================
|
|
|
|
|
|
class TestEventPayloadFormat:
|
|
"""Vérifie que les événements envoyés ont le bon format."""
|
|
|
|
def test_event_payload_matches_server_model(self):
|
|
"""Le payload event doit contenir session_id, timestamp, event."""
|
|
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
|
|
|
captured_payload = {}
|
|
|
|
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
|
mock_req.post.return_value = MagicMock(ok=True)
|
|
|
|
streamer = TraceStreamer("sess_test_003")
|
|
streamer._server_available = True
|
|
|
|
# Envoyer directement (sans thread)
|
|
test_event = {
|
|
"type": "mouse_click",
|
|
"button": "left",
|
|
"pos": (500, 300),
|
|
"timestamp": 1234567890.0,
|
|
"window": {"title": "Firefox", "app_name": "firefox"},
|
|
}
|
|
streamer._send_event(test_event)
|
|
|
|
# Vérifier le payload envoyé
|
|
event_calls = [
|
|
c for c in mock_req.post.call_args_list
|
|
if "/event" in str(c)
|
|
]
|
|
assert len(event_calls) == 1
|
|
_, kwargs = event_calls[0]
|
|
payload = kwargs["json"]
|
|
|
|
# Champs requis par le modèle Pydantic StreamEvent du serveur
|
|
assert "session_id" in payload
|
|
assert "timestamp" in payload
|
|
assert "event" in payload
|
|
assert payload["session_id"] == "sess_test_003"
|
|
assert isinstance(payload["timestamp"], float)
|
|
assert payload["event"]["type"] == "mouse_click"
|
|
|
|
def test_event_with_window_info(self):
|
|
"""Le serveur utilise event.window pour last_window_info."""
|
|
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
|
|
|
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
|
mock_req.post.return_value = MagicMock(ok=True)
|
|
|
|
streamer = TraceStreamer("sess_test_004")
|
|
streamer._server_available = True
|
|
|
|
event_with_window = {
|
|
"type": "mouse_click",
|
|
"window": {"title": "Chrome", "app_name": "chrome"},
|
|
}
|
|
streamer._send_event(event_with_window)
|
|
|
|
event_calls = [
|
|
c for c in mock_req.post.call_args_list
|
|
if "/event" in str(c)
|
|
]
|
|
payload = event_calls[0][1]["json"]
|
|
# Le champ window doit être transmis au serveur
|
|
assert "window" in payload["event"]
|
|
assert payload["event"]["window"]["title"] == "Chrome"
|
|
assert payload["event"]["window"]["app_name"] == "chrome"
|
|
|
|
|
|
class TestImagePayloadFormat:
|
|
"""Vérifie le format d'envoi des screenshots."""
|
|
|
|
def test_image_params_match_server(self, tmp_path):
|
|
"""L'envoi image utilise les bons params query (session_id, shot_id)."""
|
|
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
|
|
|
# Créer un faux fichier image
|
|
fake_img = tmp_path / "test.png"
|
|
fake_img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
|
|
|
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
|
mock_req.post.return_value = MagicMock(ok=True)
|
|
|
|
streamer = TraceStreamer("sess_test_005")
|
|
streamer._server_available = True
|
|
streamer._send_image(str(fake_img), "shot_0001_full")
|
|
|
|
img_calls = [
|
|
c for c in mock_req.post.call_args_list
|
|
if "/image" in str(c)
|
|
]
|
|
assert len(img_calls) == 1
|
|
_, kwargs = img_calls[0]
|
|
|
|
# Vérifier les params query
|
|
assert kwargs["params"]["session_id"] == "sess_test_005"
|
|
assert kwargs["params"]["shot_id"] == "shot_0001_full"
|
|
|
|
# Vérifier que le fichier est envoyé
|
|
assert "files" in kwargs
|
|
assert "file" in kwargs["files"]
|
|
|
|
def test_empty_path_ignored(self):
|
|
"""push_image avec chemin vide ne doit pas enqueue."""
|
|
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
|
|
|
streamer = TraceStreamer("sess_test_006")
|
|
streamer.push_image("", "heartbeat_empty")
|
|
assert streamer.queue.empty(), "Chemin vide ne doit pas être enfilé"
|
|
|
|
def test_crop_naming_convention(self, tmp_path):
|
|
"""Le serveur distingue full/crop par '_crop' dans le shot_id."""
|
|
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
|
|
|
# Dans le monde réel, full et crop sont deux fichiers distincts
|
|
# (la purge après ACK supprime le premier avant que le second parte).
|
|
fake_full = tmp_path / "full.png"
|
|
fake_full.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
|
fake_crop = tmp_path / "crop.png"
|
|
fake_crop.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
|
|
|
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
|
mock_req.post.return_value = MagicMock(ok=True)
|
|
|
|
streamer = TraceStreamer("sess_test_007")
|
|
streamer._server_available = True
|
|
|
|
# Full screenshot
|
|
streamer._send_image(str(fake_full), "shot_0001_full")
|
|
# Crop screenshot
|
|
streamer._send_image(str(fake_crop), "shot_0001_crop")
|
|
|
|
img_calls = [
|
|
c for c in mock_req.post.call_args_list
|
|
if "/image" in str(c)
|
|
]
|
|
assert len(img_calls) == 2
|
|
|
|
shot_ids = [c[1]["params"]["shot_id"] for c in img_calls]
|
|
assert "shot_0001_full" in shot_ids
|
|
assert "shot_0001_crop" in shot_ids
|
|
|
|
# Vérifier que le serveur pourra distinguer
|
|
# (api_stream.py check "_crop" in shot_id)
|
|
assert "_crop" in "shot_0001_crop"
|
|
assert "_crop" not in "shot_0001_full"
|
|
|
|
|
|
# =========================================================================
|
|
# Server-side validation (StreamEvent model)
|
|
# =========================================================================
|
|
|
|
|
|
class TestServerModelValidation:
|
|
"""Vérifie que les payloads client passent la validation Pydantic côté serveur."""
|
|
|
|
def test_streamevent_model_accepts_client_payload(self):
|
|
"""Le payload client est accepté par le modèle StreamEvent du serveur."""
|
|
import agent_v0.server_v1 # noqa: F401 — force le bon import
|
|
from agent_v0.server_v1.api_stream import StreamEvent
|
|
|
|
# Payload typique envoyé par le client
|
|
payload = {
|
|
"session_id": "sess_20260311T100530_abc123",
|
|
"timestamp": 1741689930.123,
|
|
"event": {
|
|
"type": "mouse_click",
|
|
"button": "left",
|
|
"pos": [500, 300],
|
|
"timestamp": 1741689930.123,
|
|
"window": {"title": "Firefox", "app_name": "firefox"},
|
|
"screenshot_id": "shot_0001",
|
|
},
|
|
}
|
|
model = StreamEvent(**payload)
|
|
assert model.session_id == "sess_20260311T100530_abc123"
|
|
assert model.event["type"] == "mouse_click"
|
|
assert model.event["window"]["title"] == "Firefox"
|
|
|
|
def test_streamevent_heartbeat(self):
|
|
"""Heartbeat events passent la validation."""
|
|
import agent_v0.server_v1 # noqa: F401
|
|
from agent_v0.server_v1.api_stream import StreamEvent
|
|
|
|
payload = {
|
|
"session_id": "sess_heartbeat",
|
|
"timestamp": 1741689935.0,
|
|
"event": {
|
|
"type": "heartbeat",
|
|
"image": "/tmp/shots/context_1741689935_heartbeat.png",
|
|
"timestamp": 1741689935.0,
|
|
},
|
|
}
|
|
model = StreamEvent(**payload)
|
|
assert model.event["type"] == "heartbeat"
|
|
|
|
def test_streamevent_window_focus_change(self):
|
|
"""Window focus change events passent la validation."""
|
|
import agent_v0.server_v1 # noqa: F401
|
|
from agent_v0.server_v1.api_stream import StreamEvent
|
|
|
|
payload = {
|
|
"session_id": "sess_focus",
|
|
"timestamp": 1741689940.0,
|
|
"event": {
|
|
"type": "window_focus_change",
|
|
"from": {"title": "Terminal", "app_name": "gnome-terminal"},
|
|
"to": {"title": "Firefox", "app_name": "firefox"},
|
|
"timestamp": 1741689940.0,
|
|
},
|
|
}
|
|
model = StreamEvent(**payload)
|
|
assert model.event["type"] == "window_focus_change"
|
|
|
|
|
|
# =========================================================================
|
|
# Server processes client data correctly
|
|
# =========================================================================
|
|
|
|
|
|
class TestServerProcessesClientData:
|
|
"""Vérifie que le serveur traite correctement les données du client."""
|
|
|
|
def test_window_info_extracted_from_event(self):
|
|
"""Le LiveSessionManager extrait window info des événements."""
|
|
import agent_v0.server_v1 # noqa: F401
|
|
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
|
|
|
mgr = LiveSessionManager()
|
|
# Événement typique envoyé par l'Agent V1
|
|
mgr.add_event("sess_client", {
|
|
"type": "mouse_click",
|
|
"button": "left",
|
|
"pos": [500, 300],
|
|
"window": {"title": "Firefox", "app_name": "firefox"},
|
|
})
|
|
|
|
session = mgr.get_session("sess_client")
|
|
assert session.last_window_info["title"] == "Firefox"
|
|
assert session.last_window_info["app_name"] == "firefox"
|
|
|
|
def test_crop_filtered_in_raw_session(self):
|
|
"""Les crops sont filtrés lors de la conversion RawSession."""
|
|
import agent_v0.server_v1 # noqa: F401
|
|
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
|
|
|
mgr = LiveSessionManager()
|
|
# Le client envoie full + crop
|
|
mgr.add_screenshot("sess_raw", "shot_0001_full", "/tmp/full.png")
|
|
mgr.add_screenshot("sess_raw", "shot_0001_crop", "/tmp/crop.png")
|
|
|
|
raw = mgr.to_raw_session("sess_raw")
|
|
# Seul le full doit apparaître dans RawSession
|
|
assert len(raw["screenshots"]) == 1
|
|
assert raw["screenshots"][0]["screenshot_id"] == "shot_0001_full"
|
|
|
|
def test_server_failure_tracking(self):
|
|
"""Le streamer désactive les envois après 10 échecs consécutifs."""
|
|
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
|
|
|
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
|
mock_req.post.return_value = MagicMock(ok=False, status_code=500)
|
|
|
|
streamer = TraceStreamer("sess_fail")
|
|
streamer._server_available = True
|
|
|
|
# 10 échecs consécutifs
|
|
for _ in range(10):
|
|
streamer._send_event({"type": "test"})
|
|
|
|
# Le streamer est toujours _server_available=True car
|
|
# c'est la boucle _stream_loop qui fait le tracking.
|
|
# Mais _send_event retourne False
|
|
assert not streamer._send_event({"type": "test"})
|