feat: journée 17 avril — tests E2E validés, dashboard fleet+audit, VWB bridge, cleaner C2
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Pipeline E2E complet validé : Capture VM → streaming → serveur → cleaner → replay → audit trail Mode apprentissage supervisé fonctionne (Léa échoue → humain → reprise) Dashboard : - Cleanup 14→10 onglets (RCE supprimée) - Fleet : enregistrer/révoquer agents, tokens, ZIP pré-configuré téléchargeable - Audit trail MVP (/audit) : filtres, tableau, export CSV, conformité AI Act/RGPD - Formulaire Fleet simplifié (nom + email, machine_id auto) VWB bridge Léa→VWB : - Compound décomposés en N steps (saisie + raccourci visibles) - Layout serpentin 3 colonnes (plus colonne verticale) - Badge OS 🪟/🐧, filtre OS retiré (admin Linux voit Windows) - Fix import SQLite readonly Cleaner intelligent : - Descriptions lisibles (UIA/C2) + détection doublons - Logique C2 : UIElement identifié = jamais parasite - Patterns parasites resserrés - Message Léa : "Je n'y arrive pas, montrez-moi comment faire" Config agent (INC-1 à INC-7) : - SERVER_URL + SERVER_BASE unifiés - RPA_OLLAMA_HOST séparé - allow_redirects=False sur POST - Middleware réécriture URL serveur CI Gitea : fix token + Flask-SocketIO + ruff propre Fleet endpoints : /agents/enroll|uninstall|fleet + agent_registry SQLite Backup : script quotidien workflows.db + audit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
195
tests/unit/test_agent_config.py
Normal file
195
tests/unit/test_agent_config.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# tests/unit/test_agent_config.py
|
||||
"""
|
||||
Tests unitaires pour la convention de configuration agent (INC-1 a INC-7).
|
||||
|
||||
Verifie que :
|
||||
- STREAMING_ENDPOINT contient /api/v1/traces/stream
|
||||
- SERVER_BASE est l'URL sans /api/v1 (pour /health)
|
||||
- Le health check utilise la racine, pas /api/v1
|
||||
- OLLAMA_HOST est separe de SERVER_URL
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAgentConfig:
|
||||
"""Tests de la resolution d'URL dans agent_v1.config."""
|
||||
|
||||
def test_streaming_endpoint_includes_api_v1(self, monkeypatch):
|
||||
"""STREAMING_ENDPOINT doit contenir /api/v1/traces/stream."""
|
||||
monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1")
|
||||
# Recharger le module config pour prendre en compte la variable
|
||||
import importlib
|
||||
from agent_v0.agent_v1 import config
|
||||
importlib.reload(config)
|
||||
|
||||
assert "/api/v1/traces/stream" in config.STREAMING_ENDPOINT
|
||||
assert config.STREAMING_ENDPOINT == "http://192.168.1.40:5005/api/v1/traces/stream"
|
||||
|
||||
def test_streaming_endpoint_default(self, monkeypatch):
|
||||
"""Endpoint par defaut (localhost:5005)."""
|
||||
monkeypatch.delenv("RPA_SERVER_URL", raising=False)
|
||||
import importlib
|
||||
from agent_v0.agent_v1 import config
|
||||
importlib.reload(config)
|
||||
|
||||
assert config.STREAMING_ENDPOINT == "http://localhost:5005/api/v1/traces/stream"
|
||||
|
||||
def test_server_base_strips_api_v1(self, monkeypatch):
|
||||
"""SERVER_BASE doit etre l'URL sans /api/v1."""
|
||||
monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1")
|
||||
import importlib
|
||||
from agent_v0.agent_v1 import config
|
||||
importlib.reload(config)
|
||||
|
||||
assert config.SERVER_BASE == "http://192.168.1.40:5005"
|
||||
assert "/api/v1" not in config.SERVER_BASE
|
||||
|
||||
def test_server_base_https_domain(self, monkeypatch):
|
||||
"""SERVER_BASE avec un domaine HTTPS (reverse proxy)."""
|
||||
monkeypatch.setenv(
|
||||
"RPA_SERVER_URL", "https://lea.labs.laurinebazin.design/api/v1"
|
||||
)
|
||||
import importlib
|
||||
from agent_v0.agent_v1 import config
|
||||
importlib.reload(config)
|
||||
|
||||
assert config.SERVER_BASE == "https://lea.labs.laurinebazin.design"
|
||||
|
||||
def test_health_url_is_root_not_api_v1(self, monkeypatch):
|
||||
"""Le health check doit etre sur SERVER_BASE/health (racine)."""
|
||||
monkeypatch.setenv(
|
||||
"RPA_SERVER_URL", "https://lea.labs.laurinebazin.design/api/v1"
|
||||
)
|
||||
import importlib
|
||||
from agent_v0.agent_v1 import config
|
||||
importlib.reload(config)
|
||||
|
||||
health_url = f"{config.SERVER_BASE}/health"
|
||||
assert health_url == "https://lea.labs.laurinebazin.design/health"
|
||||
assert "/api/v1/health" not in health_url
|
||||
|
||||
def test_ollama_host_separate_from_server(self, monkeypatch):
|
||||
"""OLLAMA_HOST est independant de SERVER_URL."""
|
||||
monkeypatch.setenv(
|
||||
"RPA_SERVER_URL", "https://lea.labs.laurinebazin.design/api/v1"
|
||||
)
|
||||
monkeypatch.delenv("RPA_OLLAMA_HOST", raising=False)
|
||||
import importlib
|
||||
from agent_v0.agent_v1 import config
|
||||
importlib.reload(config)
|
||||
|
||||
# Par defaut, Ollama est en local
|
||||
assert config.OLLAMA_HOST == "localhost"
|
||||
|
||||
def test_ollama_host_custom(self, monkeypatch):
|
||||
"""OLLAMA_HOST peut etre configure separement."""
|
||||
monkeypatch.setenv("RPA_OLLAMA_HOST", "192.168.1.40")
|
||||
import importlib
|
||||
from agent_v0.agent_v1 import config
|
||||
importlib.reload(config)
|
||||
|
||||
assert config.OLLAMA_HOST == "192.168.1.40"
|
||||
|
||||
def test_no_double_api_v1(self, monkeypatch):
|
||||
"""Aucune URL ne doit contenir /api/v1/api/v1 (double prefixe)."""
|
||||
monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1")
|
||||
import importlib
|
||||
from agent_v0.agent_v1 import config
|
||||
importlib.reload(config)
|
||||
|
||||
all_urls = [
|
||||
config.SERVER_URL,
|
||||
config.SERVER_BASE,
|
||||
config.STREAMING_ENDPOINT,
|
||||
config.UPLOAD_ENDPOINT,
|
||||
]
|
||||
for url in all_urls:
|
||||
assert "/api/v1/api/v1" not in url, f"Double /api/v1 dans : {url}"
|
||||
|
||||
|
||||
class TestServerClientUrls:
|
||||
"""Tests de la resolution d'URL dans lea_ui.server_client."""
|
||||
|
||||
def test_stream_url_includes_api_v1(self, monkeypatch):
|
||||
"""_stream_url doit contenir /api/v1."""
|
||||
monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1")
|
||||
from agent_v0.lea_ui.server_client import LeaServerClient
|
||||
|
||||
client = LeaServerClient()
|
||||
assert "/api/v1" in client._stream_url
|
||||
assert client._stream_url.endswith("/api/v1")
|
||||
|
||||
def test_stream_base_no_api_v1(self, monkeypatch):
|
||||
"""_stream_base ne doit PAS contenir /api/v1."""
|
||||
monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1")
|
||||
from agent_v0.lea_ui.server_client import LeaServerClient
|
||||
|
||||
client = LeaServerClient()
|
||||
assert "/api/v1" not in client._stream_base
|
||||
|
||||
def test_health_on_root(self, monkeypatch):
|
||||
"""Le health check doit pointer sur la racine."""
|
||||
monkeypatch.setenv(
|
||||
"RPA_SERVER_URL", "https://lea.labs.laurinebazin.design/api/v1"
|
||||
)
|
||||
from agent_v0.lea_ui.server_client import LeaServerClient
|
||||
|
||||
client = LeaServerClient()
|
||||
health_url = f"{client._stream_base}/health"
|
||||
assert health_url == "https://lea.labs.laurinebazin.design/health"
|
||||
|
||||
def test_workflows_url_no_double_api_v1(self, monkeypatch):
|
||||
"""L'URL workflows ne doit pas avoir /api/v1/api/v1."""
|
||||
monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1")
|
||||
from agent_v0.lea_ui.server_client import LeaServerClient
|
||||
|
||||
client = LeaServerClient()
|
||||
workflows_url = f"{client._stream_url}/traces/stream/workflows"
|
||||
assert "/api/v1/api/v1" not in workflows_url
|
||||
assert workflows_url == "http://192.168.1.40:5005/api/v1/traces/stream/workflows"
|
||||
|
||||
|
||||
class TestScenarios:
|
||||
"""Validation des 3 scenarios de deploiement."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"server_url,expected_stream,expected_base,expected_health",
|
||||
[
|
||||
# Scenario 1 : LAN interne
|
||||
(
|
||||
"http://192.168.1.40:5005/api/v1",
|
||||
"http://192.168.1.40:5005/api/v1/traces/stream",
|
||||
"http://192.168.1.40:5005",
|
||||
"http://192.168.1.40:5005/health",
|
||||
),
|
||||
# Scenario 2 : Internet via NPM
|
||||
(
|
||||
"https://lea.labs.laurinebazin.design/api/v1",
|
||||
"https://lea.labs.laurinebazin.design/api/v1/traces/stream",
|
||||
"https://lea.labs.laurinebazin.design",
|
||||
"https://lea.labs.laurinebazin.design/health",
|
||||
),
|
||||
# Scenario 3 : Dev local (defaut)
|
||||
(
|
||||
"http://localhost:5005/api/v1",
|
||||
"http://localhost:5005/api/v1/traces/stream",
|
||||
"http://localhost:5005",
|
||||
"http://localhost:5005/health",
|
||||
),
|
||||
],
|
||||
ids=["lan", "internet", "localhost"],
|
||||
)
|
||||
def test_scenario_urls(
|
||||
self, monkeypatch, server_url, expected_stream, expected_base, expected_health
|
||||
):
|
||||
"""Valider la matrice URL pour chaque scenario de deploiement."""
|
||||
monkeypatch.setenv("RPA_SERVER_URL", server_url)
|
||||
import importlib
|
||||
from agent_v0.agent_v1 import config
|
||||
importlib.reload(config)
|
||||
|
||||
assert config.STREAMING_ENDPOINT == expected_stream
|
||||
assert config.SERVER_BASE == expected_base
|
||||
assert f"{config.SERVER_BASE}/health" == expected_health
|
||||
655
tests/unit/test_session_cleaner.py
Normal file
655
tests/unit/test_session_cleaner.py
Normal file
@@ -0,0 +1,655 @@
|
||||
"""Tests pour tools/session_cleaner.py — precision de la detection parasite.
|
||||
|
||||
Ces tests verifient que la detection parasite est assez fine pour les
|
||||
logiciels metier hospitaliers (DPI, codage PMSI, facturation) ou les
|
||||
interfaces sont complexes (onglets, dialogues, assistants).
|
||||
|
||||
Regle d'or : mieux vaut garder un parasite que supprimer un vrai clic.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
# Ajouter le repertoire racine au path pour importer tools.session_cleaner
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from tools.session_cleaner import (
|
||||
_ACTIONABLE_TYPES,
|
||||
_PARASITIC_WINDOW_PATTERNS,
|
||||
_get_app_name,
|
||||
_has_identified_ui_element,
|
||||
_is_parasitic,
|
||||
_is_stop_recording_event,
|
||||
_is_systray_interaction,
|
||||
_parse_actions,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers pour construire des evenements de test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_event(
|
||||
etype: str = "mouse_click",
|
||||
window_title: str = "",
|
||||
app_name: str = "",
|
||||
button: str = "left",
|
||||
pos: list = None,
|
||||
keys: list = None,
|
||||
text: str = "",
|
||||
uia_name: str = "",
|
||||
uia_parent_path: list = None,
|
||||
ui_elements: list = None,
|
||||
vision_ui_elements: list = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Construire un evenement minimal pour les tests.
|
||||
|
||||
Parametres supplementaires pour la logique C2/UIA :
|
||||
- uia_name : nom de l'element UIA (bouton, champ, etc.)
|
||||
- ui_elements : liste d'elements C2 detectes par le pipeline vision
|
||||
- vision_ui_elements : idem, place dans vision_info.ui_elements
|
||||
"""
|
||||
inner: Dict[str, Any] = {"type": etype}
|
||||
|
||||
if window_title or app_name:
|
||||
inner["window"] = {"title": window_title, "app_name": app_name}
|
||||
|
||||
if etype == "mouse_click":
|
||||
inner["button"] = button
|
||||
inner["pos"] = pos or [640, 400]
|
||||
|
||||
if etype in ("key_combo", "key_press"):
|
||||
inner["keys"] = keys or []
|
||||
|
||||
if etype in ("text_input", "type"):
|
||||
inner["text"] = text
|
||||
|
||||
if uia_name or uia_parent_path:
|
||||
inner["uia_snapshot"] = {
|
||||
"name": uia_name,
|
||||
"control_type": "",
|
||||
"parent_path": uia_parent_path or [],
|
||||
}
|
||||
|
||||
if ui_elements is not None:
|
||||
inner["ui_elements"] = ui_elements
|
||||
|
||||
if vision_ui_elements is not None:
|
||||
inner["vision_info"] = {"ui_elements": vision_ui_elements}
|
||||
|
||||
return {"event": inner, "session_id": "test_sess", "machine_id": "test"}
|
||||
|
||||
|
||||
def _wrap_session(actionable_events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Entourer d'evenements non-exploitables pour simuler une vraie session."""
|
||||
hb = {"event": {"type": "heartbeat"}, "session_id": "test_sess", "machine_id": "test"}
|
||||
return [hb] + actionable_events + [hb]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 1 — Premier clic sur le bureau (Program Manager) pas parasite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBug1ProgramManager:
|
||||
"""Program Manager ne doit plus jamais etre marque parasite."""
|
||||
|
||||
def test_first_click_on_desktop_not_parasitic(self):
|
||||
"""Premier clic avec window='Program Manager' → NOT parasite.
|
||||
|
||||
Cas reel : l'utilisateur demarre un workflow depuis le bureau
|
||||
Windows en cliquant sur une icone ou la barre des taches.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="Program Manager",
|
||||
app_name="explorer.exe",
|
||||
pos=[640, 400],
|
||||
)
|
||||
assert not _is_parasitic(event, index=0, total=10)
|
||||
|
||||
def test_middle_click_on_program_manager_not_parasitic(self):
|
||||
"""Clic milieu de workflow sur bureau → NOT parasite.
|
||||
|
||||
Choix pragmatique : meme si l'utilisateur clique dans le vide,
|
||||
c'est a lui de decider via l'interface du cleaner. Pas de
|
||||
suppression automatique.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="Program Manager",
|
||||
app_name="explorer.exe",
|
||||
pos=[640, 400],
|
||||
)
|
||||
# Index 5 sur 10 = milieu de session
|
||||
assert not _is_parasitic(event, index=5, total=10)
|
||||
|
||||
def test_program_manager_not_in_patterns(self):
|
||||
"""'program manager' ne doit pas figurer dans les patterns parasites."""
|
||||
for pattern in _PARASITIC_WINDOW_PATTERNS:
|
||||
assert "program manager" not in pattern.lower(), (
|
||||
f"'program manager' ne doit pas etre dans _PARASITIC_WINDOW_PATTERNS "
|
||||
f"(trouve dans '{pattern}')"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 2 — Derniers clics : pas de regle blanket "3 derniers"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBug2LastEventsNotBlanketParasitic:
|
||||
"""Les derniers clics ne doivent plus etre automatiquement parasites."""
|
||||
|
||||
def test_last_click_on_notepad_not_parasitic(self):
|
||||
"""Dernier clic avec window='Bloc-notes' → NOT parasite.
|
||||
|
||||
Cas reel : l'utilisateur valide/sauvegarde dans son dernier clic.
|
||||
L'ancienne regle supprimait systematiquement les 3 derniers clics.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="Bloc-notes",
|
||||
app_name="Notepad.exe",
|
||||
pos=[500, 300],
|
||||
)
|
||||
# Dernier evenement sur 10
|
||||
assert not _is_parasitic(event, index=9, total=10)
|
||||
|
||||
def test_last_click_on_dpi_not_parasitic(self):
|
||||
"""Dernier clic sur DPI urgences → NOT parasite.
|
||||
|
||||
Cas critique : le dernier clic est souvent 'Valider le codage'
|
||||
ou 'Sauvegarder la fiche patient'.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="DPI Urgences - Validation du codage",
|
||||
app_name="dpi.exe",
|
||||
pos=[800, 600],
|
||||
)
|
||||
assert not _is_parasitic(event, index=9, total=10)
|
||||
|
||||
def test_second_to_last_not_parasitic(self):
|
||||
"""Avant-dernier clic metier → NOT parasite."""
|
||||
event = _make_event(
|
||||
window_title="Facturation MCO - Saisie actes",
|
||||
app_name="facturation.exe",
|
||||
pos=[500, 400],
|
||||
)
|
||||
assert not _is_parasitic(event, index=8, total=10)
|
||||
|
||||
def test_last_click_on_lea_systray_is_parasitic_via_stop(self):
|
||||
"""Dernier clic sur Lea RPA (pythonw.exe) → parasite via stop_recording.
|
||||
|
||||
L'utilisateur clique sur l'icone Lea dans la systray pour arreter.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="unknown_window",
|
||||
app_name="pythonw.exe",
|
||||
pos=[917, 522],
|
||||
uia_name="Contexte",
|
||||
)
|
||||
assert _is_stop_recording_event(event, is_last_actionable=True)
|
||||
|
||||
def test_not_last_click_on_pythonw_not_stop(self):
|
||||
"""Clic sur pythonw.exe qui n'est PAS le dernier → pas stop_recording."""
|
||||
event = _make_event(
|
||||
window_title="unknown_window",
|
||||
app_name="pythonw.exe",
|
||||
pos=[917, 522],
|
||||
)
|
||||
assert not _is_stop_recording_event(event, is_last_actionable=False)
|
||||
|
||||
def test_ctrl_shift_l_stop_is_parasitic(self):
|
||||
"""key_combo Ctrl+Shift+L en dernier → parasite (arret explicite)."""
|
||||
event = _make_event(
|
||||
etype="key_combo",
|
||||
window_title="Bloc-notes",
|
||||
app_name="Notepad.exe",
|
||||
keys=["ctrl", "shift", "l"],
|
||||
)
|
||||
assert _is_stop_recording_event(event, is_last_actionable=True)
|
||||
|
||||
def test_ctrl_shift_l_not_last_not_stop(self):
|
||||
"""Ctrl+Shift+L au milieu de session → pas un arret."""
|
||||
event = _make_event(
|
||||
etype="key_combo",
|
||||
window_title="Bloc-notes",
|
||||
app_name="Notepad.exe",
|
||||
keys=["ctrl", "shift", "l"],
|
||||
)
|
||||
assert not _is_stop_recording_event(event, is_last_actionable=False)
|
||||
|
||||
def test_ctrl_s_last_not_stop(self):
|
||||
"""Ctrl+S en dernier → NOT stop_recording (c'est un Ctrl+S, pas Ctrl+Shift).
|
||||
|
||||
Sauvegarder est un geste metier, pas un arret.
|
||||
"""
|
||||
event = _make_event(
|
||||
etype="key_combo",
|
||||
window_title="Bloc-notes",
|
||||
app_name="Notepad.exe",
|
||||
keys=["ctrl", "s"],
|
||||
)
|
||||
# Ctrl+S n'a pas 'shift', donc pas un stop
|
||||
assert not _is_stop_recording_event(event, is_last_actionable=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 3 — Pattern "assistant" trop large
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBug3AssistantPattern:
|
||||
"""'assistant' ne doit plus etre un pattern parasite."""
|
||||
|
||||
def test_assistant_in_dpi_not_parasitic(self):
|
||||
"""Clic avec window='Assistant de codage PMSI' → NOT parasite.
|
||||
|
||||
Les logiciels metier hospitaliers utilisent souvent 'Assistant'
|
||||
dans leurs titres de fenetre.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="Assistant de codage PMSI",
|
||||
app_name="dpi.exe",
|
||||
pos=[600, 400],
|
||||
)
|
||||
assert not _is_parasitic(event, index=5, total=10)
|
||||
|
||||
def test_assistant_facturation_not_parasitic(self):
|
||||
"""'Assistant facturation' → NOT parasite."""
|
||||
event = _make_event(
|
||||
window_title="Assistant facturation - Etape 2/4",
|
||||
app_name="facturation.exe",
|
||||
pos=[500, 300],
|
||||
)
|
||||
assert not _is_parasitic(event, index=3, total=10)
|
||||
|
||||
def test_assistant_saisie_not_parasitic(self):
|
||||
"""'Assistant de saisie' → NOT parasite."""
|
||||
event = _make_event(
|
||||
window_title="Assistant de saisie des actes CCAM",
|
||||
app_name="saisie.exe",
|
||||
pos=[700, 500],
|
||||
)
|
||||
assert not _is_parasitic(event, index=4, total=10)
|
||||
|
||||
def test_assistant_not_in_patterns(self):
|
||||
"""'assistant' ne doit pas figurer tel quel dans les patterns."""
|
||||
for pattern in _PARASITIC_WINDOW_PATTERNS:
|
||||
# "assistant" seul ne doit pas y etre ;
|
||||
# "lea - rpa assistant" ou similaire est OK car specifique
|
||||
if pattern == "assistant":
|
||||
pytest.fail(
|
||||
"'assistant' seul est trop large pour les logiciels metier. "
|
||||
"Utiliser un pattern plus specifique si necessaire."
|
||||
)
|
||||
|
||||
def test_lea_rpa_pattern_matches(self):
|
||||
"""'Léa - RPA' est bien detecte comme parasite."""
|
||||
event = _make_event(
|
||||
window_title="Léa - RPA Vision",
|
||||
app_name="pythonw.exe",
|
||||
pos=[400, 300],
|
||||
)
|
||||
assert _is_parasitic(event, index=5, total=10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Systray detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSystrayDetection:
|
||||
"""La detection d'interaction systray doit etre precise."""
|
||||
|
||||
def test_icones_cachees_is_systray(self):
|
||||
"""Clic sur 'Afficher les icônes cachées' → systray."""
|
||||
event = _make_event(
|
||||
window_title="unknown_window",
|
||||
app_name="explorer.exe",
|
||||
pos=[1080, 773],
|
||||
uia_name="Afficher les icônes cachées",
|
||||
)
|
||||
assert _is_systray_interaction(event)
|
||||
|
||||
def test_depassement_parent_is_systray(self):
|
||||
"""Element dont le parent est 'Fenêtre de dépassement' → systray."""
|
||||
event = _make_event(
|
||||
window_title="Fenêtre de dépassement",
|
||||
app_name="explorer.exe",
|
||||
pos=[1026, 710],
|
||||
uia_parent_path=[
|
||||
{"name": "Bureau 1", "control_type": "volet"},
|
||||
{"name": "Fenêtre de dépassement de capacité", "control_type": "volet"},
|
||||
],
|
||||
)
|
||||
assert _is_systray_interaction(event)
|
||||
|
||||
def test_normal_button_not_systray(self):
|
||||
"""Bouton normal dans une application → pas systray."""
|
||||
event = _make_event(
|
||||
window_title="Bloc-notes",
|
||||
app_name="Notepad.exe",
|
||||
pos=[500, 300],
|
||||
uia_name="Enregistrer",
|
||||
uia_parent_path=[
|
||||
{"name": "Barre de menu", "control_type": "barre de menu"},
|
||||
],
|
||||
)
|
||||
assert not _is_systray_interaction(event)
|
||||
|
||||
def test_no_uia_not_systray(self):
|
||||
"""Pas de uia_snapshot → pas systray."""
|
||||
event = _make_event(
|
||||
window_title="Bloc-notes",
|
||||
app_name="Notepad.exe",
|
||||
pos=[500, 300],
|
||||
)
|
||||
assert not _is_systray_interaction(event)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C2/UIA — signaux positifs : element identifie = jamais parasite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestC2UIAPositiveSignals:
|
||||
"""Si C2 ou UIA identifie un element UI reel, le clic est preserve.
|
||||
|
||||
Principe : un clic sur un bouton/champ/onglet identifie par nom dans
|
||||
UIA ou par le pipeline C2 est un acte metier reel, meme si la fenetre
|
||||
a un titre bizarre ou inconnu.
|
||||
"""
|
||||
|
||||
def test_click_with_uia_element_never_parasitic(self):
|
||||
"""Clic avec uia_snapshot.name='Enregistrer' → NOT parasite.
|
||||
|
||||
Meme si la fenetre a un titre etrange, un element UI nomme
|
||||
est la preuve que c'est un vrai clic metier.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="Fenetre bizarre",
|
||||
app_name="app_metier.exe",
|
||||
pos=[600, 400],
|
||||
uia_name="Enregistrer",
|
||||
)
|
||||
assert not _is_parasitic(event, index=5, total=10)
|
||||
|
||||
def test_click_with_uia_element_overrides_unknown_window(self):
|
||||
"""Clic avec UIA identifie dans 'unknown_window' → NOT parasite.
|
||||
|
||||
Cas reel : certaines fenetres ont des titres non resolus par
|
||||
l'agent mais l'UIA snapshot identifie quand meme l'element.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="unknown_window",
|
||||
app_name="dpi.exe",
|
||||
pos=[400, 300],
|
||||
uia_name="Valider le codage",
|
||||
)
|
||||
assert not _is_parasitic(event, index=8, total=10)
|
||||
|
||||
def test_click_with_c2_ui_elements_never_parasitic(self):
|
||||
"""Clic avec ui_elements (pipeline C2) → NOT parasite.
|
||||
|
||||
Quand le pipeline C2 enrichit l'evenement avec des elements
|
||||
visuels detectes, c'est un signal fort de clic metier.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="unknown_window",
|
||||
app_name="app_metier.exe",
|
||||
pos=[600, 400],
|
||||
ui_elements=[{"label": "Sauvegarder", "bbox": [580, 380, 620, 420]}],
|
||||
)
|
||||
assert not _is_parasitic(event, index=5, total=10)
|
||||
|
||||
def test_click_with_vision_info_ui_elements_never_parasitic(self):
|
||||
"""Clic avec vision_info.ui_elements (format alternatif C2) → NOT parasite."""
|
||||
event = _make_event(
|
||||
window_title="unknown_window",
|
||||
app_name="facturation.exe",
|
||||
pos=[500, 350],
|
||||
vision_ui_elements=[{"type": "button", "text": "Confirmer"}],
|
||||
)
|
||||
assert not _is_parasitic(event, index=6, total=10)
|
||||
|
||||
def test_uia_systray_still_parasitic(self):
|
||||
"""Clic avec UIA identifie MAIS sur la systray → reste parasite.
|
||||
|
||||
Le shield C2/UIA ne s'applique pas a la systray. Un clic sur
|
||||
'Afficher les icônes cachées' est toujours parasite meme si
|
||||
l'UIA a identifie l'element.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="unknown_window",
|
||||
app_name="explorer.exe",
|
||||
pos=[1080, 773],
|
||||
uia_name="Afficher les icônes cachées",
|
||||
)
|
||||
# _is_parasitic retourne False (UIA identifie + pas pattern fenetre)
|
||||
# mais _is_systray_interaction le rattrape
|
||||
assert _is_systray_interaction(event)
|
||||
|
||||
def test_right_click_with_uia_still_parasitic(self):
|
||||
"""Clic droit avec UIA identifie → reste parasite.
|
||||
|
||||
Les clics droit sont un signal dur — meme si l'UIA a identifie
|
||||
un element, un clic droit n'est jamais un clic metier exploitable.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="Bloc-notes",
|
||||
app_name="Notepad.exe",
|
||||
pos=[500, 300],
|
||||
button="right",
|
||||
uia_name="Zone de texte",
|
||||
)
|
||||
assert _is_parasitic(event, index=5, total=10)
|
||||
|
||||
|
||||
class TestC2AppNameShield:
|
||||
"""Si l'app est une application metier connue, les clics sont preserves."""
|
||||
|
||||
def test_click_in_known_app_not_parasitic(self):
|
||||
"""Clic dans app_name='Notepad.exe' sans UIA → NOT parasite.
|
||||
|
||||
Une vraie application (pas explorer, pas pythonw) est une app
|
||||
metier — ses clics sont legitimes meme sans info UIA/C2.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="Sans titre",
|
||||
app_name="Notepad.exe",
|
||||
pos=[400, 300],
|
||||
)
|
||||
assert not _is_parasitic(event, index=5, total=10)
|
||||
|
||||
def test_click_in_dpi_not_parasitic(self):
|
||||
"""Clic dans dpi.exe sans UIA → NOT parasite."""
|
||||
event = _make_event(
|
||||
window_title="DPI Urgences",
|
||||
app_name="dpi.exe",
|
||||
pos=[600, 400],
|
||||
)
|
||||
assert not _is_parasitic(event, index=3, total=10)
|
||||
|
||||
def test_click_in_searchhost_not_parasitic(self):
|
||||
"""Clic dans SearchHost.exe → NOT parasite.
|
||||
|
||||
La recherche Windows (SearchHost.exe) est une application
|
||||
normale, pas un process systeme a filtrer.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="Rechercher",
|
||||
app_name="SearchHost.exe",
|
||||
pos=[500, 300],
|
||||
)
|
||||
assert not _is_parasitic(event, index=1, total=10)
|
||||
|
||||
def test_click_in_explorer_no_uia_can_be_parasitic(self):
|
||||
"""Clic dans explorer.exe sans UIA → peut etre parasite si pattern.
|
||||
|
||||
explorer.exe est un cas particulier : c'est a la fois le bureau
|
||||
(Program Manager) et l'explorateur de fichiers. Sans UIA pour
|
||||
distinguer, on laisse les patterns de fenetre decider.
|
||||
"""
|
||||
event = _make_event(
|
||||
window_title="Fenêtre de dépassement de capacité",
|
||||
app_name="explorer.exe",
|
||||
pos=[1026, 710],
|
||||
)
|
||||
# explorer.exe est dans _NON_BUSINESS_APPS, donc pas de shield
|
||||
# et le pattern "fenêtre de dépassement" matche → parasite
|
||||
assert _is_parasitic(event, index=5, total=10)
|
||||
|
||||
def test_click_in_pythonw_no_shield(self):
|
||||
"""Clic dans pythonw.exe → pas de shield (c'est l'agent Lea)."""
|
||||
event = _make_event(
|
||||
window_title="unknown_window",
|
||||
app_name="pythonw.exe",
|
||||
pos=[917, 522],
|
||||
)
|
||||
# pythonw.exe est dans _NON_BUSINESS_APPS, pas de shield app
|
||||
# mais pas de pattern de fenetre non plus → pas parasite via _is_parasitic
|
||||
# (sera attrape par _is_stop_recording_event si c'est le dernier)
|
||||
assert not _is_parasitic(event, index=5, total=10)
|
||||
|
||||
|
||||
class TestHasIdentifiedUIElement:
|
||||
"""Tests unitaires pour _has_identified_ui_element()."""
|
||||
|
||||
def test_uia_name_detected(self):
|
||||
"""uia_snapshot avec nom → element identifie."""
|
||||
event = _make_event(uia_name="Bouton OK")
|
||||
assert _has_identified_ui_element(event)
|
||||
|
||||
def test_uia_empty_name_not_detected(self):
|
||||
"""uia_snapshot avec nom vide → pas d'element identifie."""
|
||||
event = _make_event(uia_name="")
|
||||
assert not _has_identified_ui_element(event)
|
||||
|
||||
def test_no_uia_not_detected(self):
|
||||
"""Pas de uia_snapshot → pas d'element identifie."""
|
||||
event = _make_event()
|
||||
assert not _has_identified_ui_element(event)
|
||||
|
||||
def test_c2_ui_elements_detected(self):
|
||||
"""ui_elements (C2) → element identifie."""
|
||||
event = _make_event(ui_elements=[{"label": "Sauvegarder"}])
|
||||
assert _has_identified_ui_element(event)
|
||||
|
||||
def test_vision_ui_elements_detected(self):
|
||||
"""vision_info.ui_elements → element identifie."""
|
||||
event = _make_event(vision_ui_elements=[{"type": "button"}])
|
||||
assert _has_identified_ui_element(event)
|
||||
|
||||
def test_empty_ui_elements_not_detected(self):
|
||||
"""ui_elements vide → pas d'element identifie."""
|
||||
event = _make_event(ui_elements=[])
|
||||
assert not _has_identified_ui_element(event)
|
||||
|
||||
|
||||
class TestGetAppName:
|
||||
"""Tests unitaires pour _get_app_name()."""
|
||||
|
||||
def test_app_name_from_window(self):
|
||||
event = _make_event(app_name="Notepad.exe")
|
||||
assert _get_app_name(event) == "Notepad.exe"
|
||||
|
||||
def test_no_app_name(self):
|
||||
event = _make_event()
|
||||
assert _get_app_name(event) == ""
|
||||
|
||||
def test_app_name_none_returns_empty(self):
|
||||
"""app_name None → chaine vide."""
|
||||
event = {"event": {"type": "mouse_click", "window": {"title": "Test", "app_name": None}}}
|
||||
assert _get_app_name(event) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test sur la vraie session de Dom
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRealSessionDom:
|
||||
"""Verification sur la session E2E reelle de Dom.
|
||||
|
||||
Session: sess_20260417T133324_30c2d0
|
||||
Workflow: clic Rechercher → texte → clic resultat → Bloc-notes → texte →
|
||||
Ctrl+S → systray stop.
|
||||
"""
|
||||
|
||||
SESSION_PATH = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..", "..",
|
||||
"data", "training", "live_sessions", "windows_vm",
|
||||
"sess_20260417T133324_30c2d0", "live_events.jsonl",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def real_events(self):
|
||||
"""Charger les evenements reels si disponibles."""
|
||||
path = Path(self.SESSION_PATH).resolve()
|
||||
if not path.is_file():
|
||||
pytest.skip(f"Session reelle non disponible : {path}")
|
||||
events = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
events.append(json.loads(line))
|
||||
return events
|
||||
|
||||
def test_real_session_no_false_positives(self, real_events):
|
||||
"""Aucun clic metier ne doit etre marque parasite.
|
||||
|
||||
Les 7 premiers evenements exploitables sont du workflow reel :
|
||||
- Clic Rechercher (taskbar)
|
||||
- Texte dans la recherche
|
||||
- Clic sur un resultat
|
||||
- Clic Agrandir (Bloc-notes)
|
||||
- Clic Nouvel onglet
|
||||
- Texte 'bijour'
|
||||
- Ctrl+S (sauvegarder)
|
||||
|
||||
Les 4 derniers sont l'arret (systray):
|
||||
- Right-click systray
|
||||
- Left-click icones cachees
|
||||
- Right-click fenetre depassement
|
||||
- Left-click menu Lea (pythonw.exe)
|
||||
"""
|
||||
# Utiliser _parse_actions avec un dossier bidon (pas de shots)
|
||||
session_dir = Path(self.SESSION_PATH).parent
|
||||
actions = _parse_actions(real_events, session_dir)
|
||||
|
||||
# 11 evenements exploitables au total
|
||||
assert len(actions) == 11, f"Attendu 11 actions, obtenu {len(actions)}"
|
||||
|
||||
# Les 7 premiers doivent etre OK (pas parasites)
|
||||
for idx, action in enumerate(actions[:7]):
|
||||
assert not action["is_parasitic"], (
|
||||
f"Action {idx} (evt {action['global_index']}) faussement marquee parasite : "
|
||||
f"type={action['type']}, win={action['window_title']}"
|
||||
)
|
||||
|
||||
# Les 4 derniers doivent etre parasites (arret enregistrement)
|
||||
for idx, action in enumerate(actions[7:], start=7):
|
||||
assert action["is_parasitic"], (
|
||||
f"Action {idx} (evt {action['global_index']}) devrait etre parasite : "
|
||||
f"type={action['type']}, win={action['window_title']}"
|
||||
)
|
||||
|
||||
def test_real_session_parasitic_count(self, real_events):
|
||||
"""4 parasites sur 11 — pas plus, pas moins."""
|
||||
session_dir = Path(self.SESSION_PATH).parent
|
||||
actions = _parse_actions(real_events, session_dir)
|
||||
parasitic = [a for a in actions if a["is_parasitic"]]
|
||||
ok = [a for a in actions if not a["is_parasitic"]]
|
||||
assert len(parasitic) == 4, (
|
||||
f"Attendu 4 parasites, obtenu {len(parasitic)} : "
|
||||
+ ", ".join(f"evt{a['global_index']}({a['window_title'][:30]})" for a in parasitic)
|
||||
)
|
||||
assert len(ok) == 7, (
|
||||
f"Attendu 7 OK, obtenu {len(ok)}"
|
||||
)
|
||||
Reference in New Issue
Block a user