Pipeline V4 complet disponible en API :
RawTrace → /workflow/compile → WorkflowIR + ExecutionPlan → /replay/plan → Runtime
- execution_plan_runner.py : adaptateur ExecutionNode → action executor
- Substitution variables {var} dans target/text
- Fusion stratégies primary + fallbacks (OCR, template, VLM)
- Clicks: coordonnées neutralisées, resolve_engine trouve au runtime
- 35 nouveaux tests (conversion, substitution, injection queue, pipeline E2E)
- Ancien chemin build_replay_from_raw_events() préservé (coexistence)
208 tests passent, 0 régression.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
566 lines
19 KiB
Python
566 lines
19 KiB
Python
"""
|
|
Tests de execution_plan_runner — adaptateur ExecutionPlan → queue de replay.
|
|
|
|
Vérifie que :
|
|
- Un ExecutionNode est correctement converti en action replay
|
|
- Les stratégies de résolution (OCR / template / VLM) produisent le bon target_spec
|
|
- Les variables {var} et ${var} sont substituées dans les textes
|
|
- L'injection dans la queue _replay_queues est correcte (avec et sans lock)
|
|
- La conversion d'un plan complet respecte l'ordre et les limites
|
|
- Les types d'actions non exécutables sont ignorés
|
|
|
|
Ces tests sont isolés et ne dépendent pas du serveur FastAPI (on importe
|
|
uniquement execution_plan_runner et les dataclasses du core).
|
|
"""
|
|
|
|
import sys
|
|
import threading
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_ROOT = str(Path(__file__).resolve().parents[2])
|
|
if _ROOT not in sys.path:
|
|
sys.path.insert(0, _ROOT)
|
|
|
|
from core.workflow.execution_plan import (
|
|
ExecutionNode,
|
|
ExecutionPlan,
|
|
ResolutionStrategy,
|
|
SuccessCondition,
|
|
)
|
|
from core.workflow.execution_compiler import ExecutionCompiler
|
|
from core.workflow.workflow_ir import WorkflowIR
|
|
|
|
from agent_v0.server_v1.execution_plan_runner import (
|
|
execution_node_to_action,
|
|
execution_plan_to_actions,
|
|
inject_plan_into_queue,
|
|
substitute_variables,
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# Substitution de variables
|
|
# =========================================================================
|
|
|
|
|
|
class TestSubstituteVariables:
|
|
|
|
def test_substitution_curly(self):
|
|
assert substitute_variables("{nom}", {"nom": "Dupont"}) == "Dupont"
|
|
|
|
def test_substitution_dollar(self):
|
|
assert substitute_variables("${nom}", {"nom": "Dupont"}) == "Dupont"
|
|
|
|
def test_substitution_dans_phrase(self):
|
|
assert (
|
|
substitute_variables("Bonjour {nom}, votre code est ${code}",
|
|
{"nom": "Alice", "code": "A42"})
|
|
== "Bonjour Alice, votre code est A42"
|
|
)
|
|
|
|
def test_variable_inconnue_inchangee(self):
|
|
# Une variable inconnue reste dans le texte (pas de KeyError)
|
|
assert substitute_variables("{inconnu}", {"autre": "val"}) == "{inconnu}"
|
|
|
|
def test_texte_sans_variable(self):
|
|
assert substitute_variables("texte simple", {"x": "1"}) == "texte simple"
|
|
|
|
def test_texte_vide(self):
|
|
assert substitute_variables("", {"x": "1"}) == ""
|
|
|
|
def test_variables_vides(self):
|
|
assert substitute_variables("{x}", {}) == "{x}"
|
|
|
|
|
|
# =========================================================================
|
|
# Conversion ExecutionNode → action replay
|
|
# =========================================================================
|
|
|
|
|
|
class TestExecutionNodeToAction:
|
|
|
|
def test_click_avec_strategie_ocr(self):
|
|
"""Un clic avec stratégie OCR produit une action click visuelle avec by_text."""
|
|
node = ExecutionNode(
|
|
node_id="n1",
|
|
action_type="click",
|
|
intent="Cliquer sur Enregistrer",
|
|
strategy_primary=ResolutionStrategy(
|
|
method="ocr",
|
|
target_text="Enregistrer",
|
|
threshold=0.8,
|
|
),
|
|
)
|
|
action = execution_node_to_action(node)
|
|
|
|
assert action is not None
|
|
assert action["type"] == "click"
|
|
assert action["action_id"].startswith("act_plan_")
|
|
assert action["plan_node_id"] == "n1"
|
|
assert action["intention"] == "Cliquer sur Enregistrer"
|
|
assert action["visual_mode"] is True
|
|
assert "x_pct" in action and "y_pct" in action
|
|
assert action["target_spec"]["by_text"] == "Enregistrer"
|
|
|
|
def test_click_avec_strategie_template(self):
|
|
"""Un clic avec stratégie template expose l'anchor_image_base64."""
|
|
node = ExecutionNode(
|
|
node_id="n2",
|
|
action_type="click",
|
|
strategy_primary=ResolutionStrategy(
|
|
method="template",
|
|
anchor_b64="AAABBBCCCDDD",
|
|
target_text="Ouvrir",
|
|
),
|
|
)
|
|
action = execution_node_to_action(node)
|
|
|
|
assert action is not None
|
|
assert action["type"] == "click"
|
|
assert action["target_spec"]["anchor_image_base64"] == "AAABBBCCCDDD"
|
|
assert action["target_spec"]["by_text"] == "Ouvrir"
|
|
assert action["visual_mode"] is True
|
|
|
|
def test_click_avec_strategie_vlm(self):
|
|
"""Un clic avec stratégie VLM expose vlm_description."""
|
|
node = ExecutionNode(
|
|
node_id="n3",
|
|
action_type="click",
|
|
strategy_primary=ResolutionStrategy(
|
|
method="vlm",
|
|
vlm_description="bouton rouge en haut à droite",
|
|
),
|
|
)
|
|
action = execution_node_to_action(node)
|
|
|
|
assert action is not None
|
|
assert action["target_spec"]["vlm_description"] == "bouton rouge en haut à droite"
|
|
assert action["visual_mode"] is True
|
|
|
|
def test_click_avec_fallbacks_ajoute_hints(self):
|
|
"""Les fallbacks enrichissent le target_spec avec toutes les ancres disponibles."""
|
|
node = ExecutionNode(
|
|
node_id="n4",
|
|
action_type="click",
|
|
intent="Ouvrir le menu",
|
|
strategy_primary=ResolutionStrategy(method="ocr", target_text="Menu"),
|
|
strategy_fallbacks=[
|
|
ResolutionStrategy(
|
|
method="template", anchor_b64="XYZ", target_text="Menu",
|
|
),
|
|
ResolutionStrategy(
|
|
method="vlm", vlm_description="menu déroulant",
|
|
),
|
|
],
|
|
)
|
|
action = execution_node_to_action(node)
|
|
|
|
spec = action["target_spec"]
|
|
assert spec["by_text"] == "Menu"
|
|
assert spec["anchor_image_base64"] == "XYZ"
|
|
assert spec["vlm_description"] == "menu déroulant"
|
|
|
|
def test_click_avec_success_condition_expected_title(self):
|
|
"""La success_condition avec expected_title passe dans expected_window_title."""
|
|
node = ExecutionNode(
|
|
node_id="n5",
|
|
action_type="click",
|
|
strategy_primary=ResolutionStrategy(method="ocr", target_text="OK"),
|
|
success_condition=SuccessCondition(
|
|
method="title_match",
|
|
expected_title="Document sauvegardé",
|
|
),
|
|
)
|
|
action = execution_node_to_action(node)
|
|
|
|
assert action["expected_window_title"] == "Document sauvegardé"
|
|
assert action["target_spec"]["window_title"] == "Document sauvegardé"
|
|
|
|
def test_type_avec_variable_substitution(self):
|
|
"""Un node type avec variable {patient} est substitué."""
|
|
node = ExecutionNode(
|
|
node_id="n6",
|
|
action_type="type",
|
|
text="{patient}",
|
|
variable_name="patient",
|
|
)
|
|
action = execution_node_to_action(node, variables={"patient": "DUPONT"})
|
|
|
|
assert action["type"] == "type"
|
|
assert action["text"] == "DUPONT"
|
|
assert action["variable_name"] == "patient"
|
|
|
|
def test_type_sans_variable(self):
|
|
"""Un texte sans variable est inchangé."""
|
|
node = ExecutionNode(
|
|
node_id="n7",
|
|
action_type="type",
|
|
text="Bonjour",
|
|
)
|
|
action = execution_node_to_action(node)
|
|
assert action["text"] == "Bonjour"
|
|
|
|
def test_key_combo(self):
|
|
"""Un key_combo expose les touches."""
|
|
node = ExecutionNode(
|
|
node_id="n8",
|
|
action_type="key_combo",
|
|
keys=["ctrl", "s"],
|
|
)
|
|
action = execution_node_to_action(node)
|
|
|
|
assert action["type"] == "key_combo"
|
|
assert action["keys"] == ["ctrl", "s"]
|
|
|
|
def test_key_combo_vide_retourne_none(self):
|
|
"""Un key_combo sans touches est ignoré."""
|
|
node = ExecutionNode(
|
|
node_id="n9",
|
|
action_type="key_combo",
|
|
keys=[],
|
|
)
|
|
assert execution_node_to_action(node) is None
|
|
|
|
def test_wait(self):
|
|
"""Un wait expose duration_ms."""
|
|
node = ExecutionNode(
|
|
node_id="n10",
|
|
action_type="wait",
|
|
duration_ms=2500,
|
|
)
|
|
action = execution_node_to_action(node)
|
|
|
|
assert action["type"] == "wait"
|
|
assert action["duration_ms"] == 2500
|
|
|
|
def test_wait_sans_duration_default(self):
|
|
"""Un wait sans duration a un défaut de 1000ms."""
|
|
node = ExecutionNode(node_id="n11", action_type="wait")
|
|
action = execution_node_to_action(node)
|
|
assert action["duration_ms"] == 1000
|
|
|
|
def test_scroll(self):
|
|
"""Un scroll produit une action scroll."""
|
|
node = ExecutionNode(node_id="n12", action_type="scroll")
|
|
action = execution_node_to_action(node)
|
|
|
|
assert action["type"] == "scroll"
|
|
assert "delta" in action
|
|
|
|
def test_type_inconnu_retourne_none(self):
|
|
"""Un type d'action inconnu est ignoré (retourne None)."""
|
|
node = ExecutionNode(node_id="n13", action_type="unknown_thing")
|
|
assert execution_node_to_action(node) is None
|
|
|
|
def test_metadonnees_execution_propagees(self):
|
|
"""timeout_ms, max_retries, recovery_action passent dans l'action."""
|
|
node = ExecutionNode(
|
|
node_id="n14",
|
|
action_type="click",
|
|
strategy_primary=ResolutionStrategy(method="ocr", target_text="X"),
|
|
timeout_ms=15000,
|
|
max_retries=3,
|
|
recovery_action="undo",
|
|
)
|
|
action = execution_node_to_action(node)
|
|
|
|
assert action["timeout_ms"] == 15000
|
|
assert action["max_retries"] == 3
|
|
assert action["recovery_action"] == "undo"
|
|
|
|
def test_node_optionnel(self):
|
|
"""is_optional est propagé."""
|
|
node = ExecutionNode(
|
|
node_id="n15",
|
|
action_type="click",
|
|
strategy_primary=ResolutionStrategy(method="ocr", target_text="X"),
|
|
is_optional=True,
|
|
)
|
|
action = execution_node_to_action(node)
|
|
assert action["is_optional"] is True
|
|
|
|
def test_id_prefix_custom(self):
|
|
"""Le préfixe d'id peut être personnalisé."""
|
|
node = ExecutionNode(
|
|
node_id="n16",
|
|
action_type="click",
|
|
strategy_primary=ResolutionStrategy(method="ocr", target_text="X"),
|
|
)
|
|
action = execution_node_to_action(node, id_prefix="act_custom")
|
|
assert action["action_id"].startswith("act_custom_")
|
|
|
|
|
|
# =========================================================================
|
|
# Conversion ExecutionPlan → liste d'actions
|
|
# =========================================================================
|
|
|
|
|
|
class TestExecutionPlanToActions:
|
|
|
|
def _make_plan(self) -> ExecutionPlan:
|
|
plan = ExecutionPlan(
|
|
plan_id="plan_test",
|
|
workflow_id="wf_test",
|
|
version=1,
|
|
variables={"nom_fichier": "rapport.pdf"},
|
|
)
|
|
plan.nodes = [
|
|
ExecutionNode(
|
|
node_id="n1",
|
|
action_type="click",
|
|
strategy_primary=ResolutionStrategy(method="ocr", target_text="Ouvrir"),
|
|
),
|
|
ExecutionNode(
|
|
node_id="n2",
|
|
action_type="type",
|
|
text="{nom_fichier}",
|
|
variable_name="nom_fichier",
|
|
),
|
|
ExecutionNode(
|
|
node_id="n3",
|
|
action_type="key_combo",
|
|
keys=["enter"],
|
|
),
|
|
ExecutionNode(
|
|
node_id="n4",
|
|
action_type="wait",
|
|
duration_ms=1500,
|
|
),
|
|
]
|
|
plan.total_nodes = 4
|
|
return plan
|
|
|
|
def test_conversion_ordre_respecte(self):
|
|
plan = self._make_plan()
|
|
actions = execution_plan_to_actions(plan)
|
|
|
|
assert len(actions) == 4
|
|
assert actions[0]["type"] == "click"
|
|
assert actions[1]["type"] == "type"
|
|
assert actions[2]["type"] == "key_combo"
|
|
assert actions[3]["type"] == "wait"
|
|
|
|
def test_variables_du_plan_appliquees(self):
|
|
plan = self._make_plan()
|
|
actions = execution_plan_to_actions(plan)
|
|
type_action = next(a for a in actions if a["type"] == "type")
|
|
assert type_action["text"] == "rapport.pdf"
|
|
|
|
def test_variables_override(self):
|
|
"""Les variables passées en argument écrasent celles du plan."""
|
|
plan = self._make_plan()
|
|
actions = execution_plan_to_actions(
|
|
plan, variables={"nom_fichier": "facture.pdf"},
|
|
)
|
|
type_action = next(a for a in actions if a["type"] == "type")
|
|
assert type_action["text"] == "facture.pdf"
|
|
|
|
def test_plan_vide(self):
|
|
plan = ExecutionPlan(plan_id="empty", workflow_id="wf_empty")
|
|
actions = execution_plan_to_actions(plan)
|
|
assert actions == []
|
|
|
|
def test_noeud_non_convertible_ignore(self):
|
|
"""Un nœud inconnu ne bloque pas la conversion."""
|
|
plan = ExecutionPlan(plan_id="p", workflow_id="wf")
|
|
plan.nodes = [
|
|
ExecutionNode(
|
|
node_id="n1",
|
|
action_type="click",
|
|
strategy_primary=ResolutionStrategy(method="ocr", target_text="OK"),
|
|
),
|
|
ExecutionNode(node_id="n2", action_type="unknown_type"),
|
|
ExecutionNode(
|
|
node_id="n3",
|
|
action_type="type",
|
|
text="hello",
|
|
),
|
|
]
|
|
actions = execution_plan_to_actions(plan)
|
|
assert len(actions) == 2
|
|
assert actions[0]["type"] == "click"
|
|
assert actions[1]["type"] == "type"
|
|
|
|
|
|
# =========================================================================
|
|
# Injection dans la queue de replay
|
|
# =========================================================================
|
|
|
|
|
|
class TestInjectPlanIntoQueue:
|
|
|
|
def _make_simple_plan(self) -> ExecutionPlan:
|
|
plan = ExecutionPlan(plan_id="p_inj", workflow_id="wf_inj")
|
|
plan.nodes = [
|
|
ExecutionNode(
|
|
node_id="n1",
|
|
action_type="click",
|
|
strategy_primary=ResolutionStrategy(method="ocr", target_text="Go"),
|
|
),
|
|
ExecutionNode(node_id="n2", action_type="wait", duration_ms=500),
|
|
]
|
|
return plan
|
|
|
|
def test_injection_replace(self):
|
|
"""Par défaut, la queue est remplacée."""
|
|
plan = self._make_simple_plan()
|
|
queues: dict = defaultdict(list)
|
|
queues["sess_abc"] = [{"type": "click", "action_id": "old"}]
|
|
|
|
actions = inject_plan_into_queue(
|
|
plan=plan,
|
|
session_id="sess_abc",
|
|
replay_queues=queues,
|
|
)
|
|
|
|
assert len(actions) == 2
|
|
assert len(queues["sess_abc"]) == 2
|
|
# L'ancienne action a été remplacée
|
|
assert all(a["action_id"] != "old" for a in queues["sess_abc"])
|
|
|
|
def test_injection_append(self):
|
|
"""Avec replace=False, on ajoute aux actions existantes."""
|
|
plan = self._make_simple_plan()
|
|
queues: dict = defaultdict(list)
|
|
queues["sess_abc"] = [{"type": "click", "action_id": "existing"}]
|
|
|
|
inject_plan_into_queue(
|
|
plan=plan,
|
|
session_id="sess_abc",
|
|
replay_queues=queues,
|
|
replace=False,
|
|
)
|
|
|
|
assert len(queues["sess_abc"]) == 3
|
|
assert queues["sess_abc"][0]["action_id"] == "existing"
|
|
|
|
def test_injection_avec_lock(self):
|
|
"""Le lock est respecté pendant l'injection."""
|
|
plan = self._make_simple_plan()
|
|
queues: dict = defaultdict(list)
|
|
lock = threading.Lock()
|
|
|
|
actions = inject_plan_into_queue(
|
|
plan=plan,
|
|
session_id="sess_x",
|
|
replay_queues=queues,
|
|
lock=lock,
|
|
)
|
|
|
|
assert len(actions) == 2
|
|
assert len(queues["sess_x"]) == 2
|
|
# Le lock est bien libéré après l'injection
|
|
assert lock.acquire(blocking=False) is True
|
|
lock.release()
|
|
|
|
def test_injection_avec_variables(self):
|
|
"""Les variables sont substituées lors de l'injection."""
|
|
plan = ExecutionPlan(plan_id="p_var", workflow_id="wf_var")
|
|
plan.nodes = [
|
|
ExecutionNode(
|
|
node_id="n1",
|
|
action_type="type",
|
|
text="{patient}",
|
|
variable_name="patient",
|
|
),
|
|
]
|
|
|
|
queues: dict = defaultdict(list)
|
|
actions = inject_plan_into_queue(
|
|
plan=plan,
|
|
session_id="sess_v",
|
|
replay_queues=queues,
|
|
variables={"patient": "MARTIN"},
|
|
)
|
|
|
|
assert actions[0]["text"] == "MARTIN"
|
|
assert queues["sess_v"][0]["text"] == "MARTIN"
|
|
|
|
|
|
# =========================================================================
|
|
# Intégration : pipeline complet IR → Plan → Actions
|
|
# =========================================================================
|
|
|
|
|
|
class TestFullPipelineV4:
|
|
"""Teste le pipeline complet : WorkflowIR → ExecutionPlan → actions replay."""
|
|
|
|
def test_pipeline_complet_ir_vers_actions(self):
|
|
# 1. Construire un WorkflowIR
|
|
ir = WorkflowIR.new("Test pipeline V4", domain="generic")
|
|
ir.add_step(
|
|
"Ouvrir le fichier",
|
|
actions=[
|
|
{"type": "click", "target": "bouton Ouvrir", "anchor_hint": "Ouvrir"},
|
|
{"type": "wait", "duration_ms": 1000},
|
|
],
|
|
postcondition="La fenêtre Ouvrir est visible",
|
|
)
|
|
ir.add_step(
|
|
"Saisir le nom",
|
|
actions=[
|
|
{"type": "type", "text": "{nom_fichier}", "variable": True},
|
|
{"type": "key_combo", "keys": ["enter"]},
|
|
],
|
|
)
|
|
ir.add_variable("nom_fichier", description="Fichier", default="doc.pdf")
|
|
|
|
# 2. Compiler → ExecutionPlan
|
|
compiler = ExecutionCompiler()
|
|
plan = compiler.compile(ir)
|
|
assert plan.total_nodes == 4
|
|
|
|
# 3. Convertir → actions replay
|
|
actions = execution_plan_to_actions(plan)
|
|
assert len(actions) == 4
|
|
|
|
types = [a["type"] for a in actions]
|
|
assert types == ["click", "wait", "type", "key_combo"]
|
|
|
|
# Le clic a une stratégie OCR → by_text
|
|
click = actions[0]
|
|
assert click["visual_mode"] is True
|
|
assert click["target_spec"].get("by_text") == "Ouvrir"
|
|
|
|
# Le type a substitué la variable depuis le plan
|
|
type_action = actions[2]
|
|
assert type_action["text"] == "doc.pdf"
|
|
|
|
# Le key_combo a les touches
|
|
assert actions[3]["keys"] == ["enter"]
|
|
|
|
def test_pipeline_avec_params_override(self):
|
|
"""Les params passés à l'injection prévalent sur le plan."""
|
|
ir = WorkflowIR.new("Variables override")
|
|
ir.add_step("Saisie", actions=[
|
|
{"type": "type", "text": "{code}", "variable": True},
|
|
])
|
|
ir.add_variable("code", default="DEFAULT")
|
|
|
|
compiler = ExecutionCompiler()
|
|
plan = compiler.compile(ir)
|
|
|
|
actions = execution_plan_to_actions(
|
|
plan, variables={"code": "RUNTIME"},
|
|
)
|
|
assert actions[0]["text"] == "RUNTIME"
|
|
|
|
def test_pipeline_plan_serialise_et_recharge(self):
|
|
"""Le plan peut être sérialisé/rechargé puis converti en actions."""
|
|
ir = WorkflowIR.new("Roundtrip")
|
|
ir.add_step("X", actions=[
|
|
{"type": "click", "target": "btn", "anchor_hint": "Valider"},
|
|
])
|
|
compiler = ExecutionCompiler()
|
|
plan = compiler.compile(ir)
|
|
|
|
json_str = plan.to_json()
|
|
plan2 = ExecutionPlan.from_json(json_str)
|
|
|
|
actions = execution_plan_to_actions(plan2)
|
|
assert len(actions) == 1
|
|
assert actions[0]["type"] == "click"
|