"""Tests du watchdog de session interactive (résilience RDP/Citrix). Vérifie que : - Le tray est ré-affiché à la reconnexion RDP (run_ui rappelé). - Un seul tray tourne à la fois (invariant « un seul tray »). - Les threads de fond de l'agent (heartbeat/replay) ne sont JAMAIS relancés par le watchdog (il ne relance QUE l'UI). - Un Quitter explicite arrête le watchdog (pas de résurrection du tray). - Le détecteur de session Windows tombe en marche (True) hors Windows. Aucune vraie UI : run_ui et is_available sont des callables mockés. """ import sys import threading import time from unittest.mock import MagicMock # Mocker les libs GUI/Win32 avant tout import du module sous test. sys.modules.setdefault("pynput", MagicMock()) sys.modules.setdefault("pynput.mouse", MagicMock()) sys.modules.setdefault("pynput.keyboard", MagicMock()) sys.modules.setdefault("pystray", MagicMock()) from agent_v0.agent_v1.ui.session_watchdog import ( # noqa: E402 InteractiveSessionWatchdog, is_interactive_desktop_available, ) # --------------------------------------------------------------------------- # Détection de session # --------------------------------------------------------------------------- def test_detection_hors_windows_renvoie_true(monkeypatch): """Hors Windows (dev/tests Linux) : bureau toujours 'disponible'.""" monkeypatch.setattr( "agent_v0.agent_v1.ui.session_watchdog.platform.system", lambda: "Linux", ) assert is_interactive_desktop_available() is True # --------------------------------------------------------------------------- # Boucle du watchdog # --------------------------------------------------------------------------- def test_relance_ui_a_la_reconnexion(): """Session absente puis présente => le tray est (ré)affiché une fois dispo. Scénario : la 1re sonde dit 'indisponible' (RDP déconnecté), la 2e dit 'disponible' (reconnexion) => run_ui doit être appelé exactement une fois, puis le watchdog s'arrête. """ availability = iter([False, True]) run_ui_calls = [] def _run_ui(): run_ui_calls.append(time.time()) # L'agent vit jusqu'à ce que le tray ait été affiché une fois. state = {"alive": True} def _is_running(): # Après le premier affichage du tray, l'agent s'arrête. return state["alive"] and len(run_ui_calls) == 0 def _is_available(): return next(availability) wd = InteractiveSessionWatchdog( run_ui=_run_ui, is_running=_is_running, is_available=_is_available, poll_interval_s=0.01, # sonde très rapide pour le test ) wd.run() # Le tray a été (ré)affiché exactement une fois après la reconnexion. assert len(run_ui_calls) == 1 def test_reaffichage_apres_chaque_deconnexion(): """Deux cycles connexion→déconnexion => tray relancé à chaque reconnexion.""" run_ui_calls = [] # is_available toujours True ; le tray 'sort' immédiatement (déconnexion). def _run_ui(): run_ui_calls.append(1) def _is_running(): # Vivre pour 2 affichages de tray, puis arrêter. return len(run_ui_calls) < 2 wd = InteractiveSessionWatchdog( run_ui=_run_ui, is_running=_is_running, is_available=lambda: True, poll_interval_s=0.01, ) wd.run() assert len(run_ui_calls) == 2 def test_un_seul_tray_a_la_fois(): """L'invariant 'un seul tray' : run_ui n'est jamais réentrant en parallèle.""" concurrent = {"count": 0, "max": 0} lock = threading.Lock() def _run_ui(): with lock: concurrent["count"] += 1 concurrent["max"] = max(concurrent["max"], concurrent["count"]) time.sleep(0.02) # simule un tray qui tourne un peu with lock: concurrent["count"] -= 1 calls = {"n": 0} def _is_running(): calls["n"] += 1 return calls["n"] <= 3 # 3 cycles de tray wd = InteractiveSessionWatchdog( run_ui=_run_ui, is_running=_is_running, is_available=lambda: True, poll_interval_s=0.01, ) wd.run() # Jamais deux trays simultanés. assert concurrent["max"] == 1 def test_stop_reveille_le_watchdog_en_attente(): """stop() sort immédiatement la boucle quand la session est absente.""" run_ui_calls = [] wd = InteractiveSessionWatchdog( run_ui=lambda: run_ui_calls.append(1), is_running=lambda: True, is_available=lambda: False, # jamais de session => reste en attente poll_interval_s=60, # long : seul stop() peut débloquer ) t = threading.Thread(target=wd.run) t.start() time.sleep(0.05) # laisser entrer dans l'attente wd.stop() t.join(timeout=2) assert not t.is_alive() # le watchdog est bien sorti assert run_ui_calls == [] # aucun tray (jamais de session dispo) def test_crash_du_tray_ne_tue_pas_le_watchdog(): """Une exception dans run_ui est absorbée ; le watchdog reste maître.""" calls = {"n": 0} def _run_ui(): calls["n"] += 1 raise RuntimeError("tray HS") def _is_running(): return calls["n"] < 2 # tolérer 2 crashs puis sortir wd = InteractiveSessionWatchdog( run_ui=_run_ui, is_running=_is_running, is_available=lambda: True, poll_interval_s=0.01, ) # Ne doit PAS lever : le crash est loggé, pas propagé. wd.run() assert calls["n"] == 2 # --------------------------------------------------------------------------- # Intégration avec main._agent_should_live (Quitter vs déconnexion) # --------------------------------------------------------------------------- def test_tray_run_reentrant_ne_relance_pas_les_threads_de_fond(monkeypatch): """SmartTrayV1.run() appelé 2x (reconnexion RDP) : threads de fond 1x seulement. On vérifie que `_start_background_once` est idempotent : les threads connexion/cache et l'accueil ne démarrent qu'au premier affichage, mais une nouvelle icône pystray est recréée à chaque appel (ré-affichage). """ import threading as _threading from agent_v0.agent_v1.ui import smart_tray as smart_tray_mod tray = smart_tray_mod.SmartTrayV1.__new__(smart_tray_mod.SmartTrayV1) tray._bg_started = False tray.machine_id = "poste_test" tray.server_client = None # pas de threads réseau => simplifie tray.icon = None greet_calls = {"n": 0} hotkey_calls = {"n": 0} icons_created = {"n": 0} tray._notifier = MagicMock() tray._notifier.greet.side_effect = lambda: greet_calls.__setitem__("n", greet_calls["n"] + 1) monkeypatch.setattr(tray, "_start_hotkey", lambda: hotkey_calls.__setitem__("n", hotkey_calls["n"] + 1)) monkeypatch.setattr(tray, "_current_icon", lambda: object()) monkeypatch.setattr(tray, "_get_menu_items", lambda: []) # Icône pystray factice : run() ne bloque pas (simule une sortie immédiate). class _FakeIcon: def __init__(self, *a, **k): icons_created["n"] += 1 def run(self): return None monkeypatch.setattr(smart_tray_mod.pystray, "Icon", _FakeIcon) monkeypatch.setattr(smart_tray_mod.pystray, "Menu", lambda *a, **k: None) # Deux affichages successifs (déconnexion → reconnexion). tray.run() tray.run() # Accueil + hotkey : une seule fois (one-shot). assert greet_calls["n"] == 1 assert hotkey_calls["n"] == 1 # Mais une nouvelle icône à chaque affichage (le tray revient bien). assert icons_created["n"] == 2 def test_agent_should_live_distingue_quit_et_deconnexion(): """Quitter explicite arrête le watchdog ; une déconnexion RDP non.""" from types import SimpleNamespace from agent_v0.agent_v1.main import _agent_should_live # Agent actif, tray sans quit demandé => doit vivre (déconnexion RDP OK). agent = SimpleNamespace(running=True, ui=SimpleNamespace(_quit_requested=False)) assert _agent_should_live(agent) is True # Quitter explicite => ne doit plus vivre (pas de résurrection). agent.ui._quit_requested = True assert _agent_should_live(agent) is False # agent.running=False => ne vit plus (arrêt global). agent2 = SimpleNamespace(running=False, ui=SimpleNamespace(_quit_requested=False)) assert _agent_should_live(agent2) is False