249 lines
8.2 KiB
Python
249 lines
8.2 KiB
Python
"""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
|