02.03.02 — La machine à états du builder#
Fichier source :
src/ocarina/dsl/testing_with_railway/internals/action_chain.pyC’est ici qu’est implémentée toute la mécanique ROP : le passage par quatre états successifs typés, qui interdit littéralement (au sens du type-checker) toute fantaisie syntaxique. Le DSL est aussi son propre système immunitaire.
Vue d’ensemble#
┌─────────────────┐
start(action) → │ ActionStart[T] │ un Action[T] = Thunk[Result[T]]
└────────┬────────┘
│ .failure(failure_handler)
▼
┌─────────────────┐
│ ActionFailure[T]│
└────────┬────────┘
│ .success(success_handler)
▼
┌─────────────────┐
│ ActionSuccess[T]│
└────────┬────────┘
│ .execute() ── exécute action(), fire le handler
▼
┌─────────────────┐
│ ActionChain[T] │ contient (has_failed, result)
└──────┬──────────┘
│ .then(next_action_or_start)
┌──────────────┴──────────────┐
│ │
has_failed=True has_failed=False
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ NeutralAction- │ │ ActionStart[T] │ → cycle recommence
│ Start[T] │ │ (l'action suiv. │
│ (rail d'échec : │ │ sera exécutée) │
│ toute la chaîne │ └──────────────────┘
│ devient no-op, │
│ tout en gardant │
│ l'API fluide) │
└──────────────────┘
│
▼ (.failure → .success → .execute, tout en no-op)
┌──────────────────┐
│ ActionChain[T] │ has_failed=True, result=<le Fail accumulé>
└──────────────────┘Les types Action, FailureHandler, SuccHandler#
type Action[T] = Thunk[Result[T]] # () -> Result[T]
type FailureHandler = Callable[[Exception], None]
type SuccHandler = Effect # () -> NoneAction[T]est unThunk. Cela veut dire qu’au moment oùActionStart(action)est appelé, rien n’est exécuté. L’action est juste capturée.FailureHandlerreçoit l’exception, mais ne reçoit pas leFailni leResult. C’est volontaire : 99% des handlers veulent tracer l’exception, prendre un screenshot, et c’est tout.SuccHandlerest unEffect(sans argument). Idem : un handler de succès log un message statique, fait un screenshot, pas besoin du résultat.
Typestate Pattern : pourquoi on ne peut pas se tromper#
L’enchaînement est strictement linéaire. Chaque méthode renvoie un type différent, qui n’expose que la suite logique de l’enchaînement :
| Type | Méthodes exposées | Pas de |
|---|---|---|
ActionStart[T] | .failure(h) | .success, .execute (n’existent pas !) |
ActionFailure[T] | .success(h') | .failure (a déjà été appelé), .execute |
ActionSuccess[T] | .execute() | .failure, .success |
ActionChain[T] | .then(...), .has_failed(), .is_ok(), .result() | .failure, .success, .execute |
Extraits du Holy Book (chapitre sur la composabilité des scénarios) :
Tentative 1 : Oublier .success#
drive_page(
act(on_book_a_call_page, verify_book_call_page)
.failure(just_log_error("..."))
)
# error: Expected type 'ActionSuccess[TPOM ≤: POMBase]', got 'ActionFailure[BookCallPage]' insteadTentative 2 : Mettre .success avant .failure#
drive_page(
act(on_book_a_call_page, verify_book_call_page)
.success(log_success_with_url_and_screenshot("...")) # ← erreur ici
)
# error:
# "ActionStart[BookCallPage]" has no attribute "success"
# Unresolved attribute reference 'success' for class 'ActionStart'Tentative 3 : Inverser .failure et .success#
drive_page(
act(on_book_a_call_page, verify_book_call_page)
.success(...)
.failure(...)
)
# error:
# "ActionStart[BookCallPage]" has no attribute "success"Le checker refuse littéralement le code. La grammaire est type-level, pas seulement conventionnelle.
Le narrowing par act() : conservation du TPOM#
Le projet utilisateur définit act(pom: TPOM, action: Callable[[TPOM], TPOM]) -> ActionStart[TPOM]. Le TPOM est conservé tout au long de la chaîne :
on_homepage = Homepage(driver=driver)
act(on_homepage, verify_book_call_page)
# ^^^^^^^^^^^^^^^^^^^^^
# error: Argument 2 to "act" has incompatible type
# "Callable[[BookCallPage], BookCallPage]";
# expected "Callable[[Homepage], Homepage]"Mélanger des act hétérogènes dans un même drive_page est aussi refusé :
drive_page(
act(on_homepage, ...).failure(...).success(...),
act(on_book_a_call_page, verify_book_call_page).failure(...).success(...),
# ^^^^^^^^^^^^^^^^^^^
# error: Expected type 'ActionSuccess[Homepage]',
# got 'ActionSuccess[BookCallPage]' instead
)Cette contrainte est ce qui force transition de page = nouveau drive_page. C’est une question de sémantique : ça aide à la revue (voir les transitions de page, identifier rapidement une capture d’écran manquante). Cf. 05-create-act-hooks.md
ActionSuccess.execute()#
def execute(self) -> ActionChain[T]:
result = self.__action__() # 1. exécute l'action
if is_fail(result):
self.__failure_handler__(result.error) # 2a. fire failure
return ActionChain(has_failed=True, result=result) # rail d'échec
self.__success_handler__() # 2b. fire success
return ActionChain(has_failed=False, result=result) # rail de succès- L’action est appelée sans argument (c’est un
Thunk[Result[T]]). Tout son contexte a été capturé en closure parcreate_act. - Le handler de failure reçoit
result.error. - Le handler de success ne reçoit rien.
ActionChaincapture deux choses : un drapeau (has_failed: bool) et leResultlui-même. Le drapeau permet à.then()de prendre une décision sans refaire unisinstance(hot-path).
__action__, __failure_handler__, __success_handler__#
Triple naming en dunder (double underscores).
Ce sont des attributs internes au framework. La double underscore notation est utilisée pour signaler « ne touche pas, c’est de la plomberie ». chain_actions les lit pour reconstruire la chaîne :
def reducer(chain: ActionChain[T], step: ActionSuccess[T]) -> ActionChain[T]:
if chain.has_failed():
return chain
return (
chain.then(step.__action__)
.failure(step.__failure_handler__)
.success(step.__success_handler__)
.execute()
)L’inspiration directe vient de xhtmlboi.github.io/articles/yocaml.html. Le principe : partir d’une composition fonctionnelle « plate », explicite, avec potentiellement beaucoup de parenthèses imbriquées ; puis introduire un opérateur (ici la chaîne .failure().success().execute() + .then()) qui aplatit la lecture tout en conservant le typage. Ocarina applique exactement ce pattern, sur des actions Selenium plutôt que des rules OCaml.
Récapitulatif#
| Principe | Conséquence |
|---|---|
| Linéarité du builder | Erreur mypy si on saute une étape ou si on change l’ordre |
@final sur chaque classe | Pas d’héritage utilisateur des classes ROP |
Action[T] est un Thunk | Évaluation paresseuse ; tout est composable comme valeur |
Une seule conversion except → Fail (dans create_act) | Aucune exception en aval ; tout est valeur typée |
ActionChain porte (has_failed, result) | .then(...) décide entre ActionStart et NeutralActionStart via un simple booléen |
__action__ etc. en dunder | Plomberie, relations bidirectionnelles dans l’implémentation du framework |