Lección 3 — Construire

Les outils Playwright de haut niveau

8 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

Les outils Playwright de haut niveau

Première version d'un agent navigateur, vue sur de nombreux prototypes : un seul outil executer_playwright(code) qui laisse le modèle écrire du Python. C'est rapide à brancher et impossible à contrôler : le harness ne sait pas ce que fait le code avant de l'exécuter. Ce projet expose à la place huit outils étroits, chacun lisible par le harness avant exécution.

Le mécanisme

sequenceDiagram
  participant M as Modèle
  participant T as BrowserTools
  participant G as Guard
  participant P as Page Playwright
  M->>T: cliquer(role="button", nom="Enregistrer le statut")
  T->>P: get_by_role("button", name=..., exact=True).count()
  P-->>T: 1
  T->>G: is_effect("button", "Enregistrer le statut") ?
  G-->>T: oui, confirmation demandée puis 1 POST armé
  T->>P: click() puis wait_for_load_state()
  P-->>T: nouvelle page
  T-->>M: url, titre, arbre simplifié étiqueté non fiable
OutilArgumentsCe qu'il renvoie
se_connecteraucunétat de la page d'accueil ; le mot de passe reste dans le harness
naviguerurl (relative acceptée)arbre de la nouvelle page, après contrôle de la liste blanche
lire_pageaucunURL, titre, arbre simplifié
cliquerrole, nomarbre après le clic ; confirmation si l'action a un effet
remplir, choisirchamp (libellé), valeur ou optionaccusé de réception, sans arbre (économie de jetons)
extraire_tableaunom du tableaulignes en texte, étiquetées non fiables
capture_ecranaucunimage jointe au tour suivant : repli coûteux

La mise en œuvre

Le code est dans harness/browser.py. Trois points méritent d'être lus de près.

1. L'arbre simplifié

page.locator("body").aria_snapshot() renvoie l'arbre ARIA au format YAML. Sur la liste de 24 commandes, il pèse 7 752 caractères et 3 357 jetons, car chaque ligne de tableau répète ses cellules. La fonction simplify garde la ligne nommée et supprime ses enfants, ainsi que les libellés déjà portés par les champs :

files/harness/browser.py (extrait)
def simplify(snapshot: str) -> str:
    out: list[str] = []
    skip_deeper_than: int | None = None
    for line in snapshot.splitlines():
        indent = len(line) - len(line.lstrip())
        if skip_deeper_than is not None:
            if indent > skip_deeper_than:
                continue
            skip_deeper_than = None
        item = line.strip()
        if item.startswith(("- text:", "- caption:")):
            continue
        if item.startswith('- row "') and item.endswith(":"):
            out.append(line[:-1])
            skip_deeper_than = indent
            continue
        out.append(line)
    return "\n".join(out)

Mesure du 2026-10-15 avec count_tokens de gemini-3.5-flash : 3 357 jetons pour l'arbre brut, 1 149 pour l'arbre simplifié, et la même réponse exacte du modèle sur les deux. Sur l'exécution complète des 5 tâches, les jetons d'entrée passent de 76 000 à 91 000 (arbre brut, trois exécutions) à 46 000 ou 47 000 (arbre simplifié, deux exécutions).

2. Le clic par rôle et nom, sans ambiguïté

files/harness/browser.py (extrait)
def cliquer(self, role: str, nom: str) -> dict:
    loc = self.page.get_by_role(role, name=nom, exact=True)
    n = loc.count()
    if n == 0:
        raise Blocked(f"Aucun élément de rôle {role!r} nommé {nom!r} : relisez la page (lire_page)")
    if n > 1:
        raise Blocked(f"{n} éléments {role!r} nommés {nom!r} : la cible est ambiguë")
    if role == "link":
        self.guard.check_navigation(urljoin(self.page.url, loc.get_attribute("href") or ""))
    effect = self.guard.is_effect(role, nom)
    if effect:
        values = ", ".join(loc.evaluate(_FORM_VALUES_JS))
        self.guard.confirm(Action("cliquer", f"Cliquer « {nom} » sur {path} ({values})"))
    ...

exact=True évite que « Filtrer » corresponde aussi à « Filtrer par date ». Le refus des cibles multiples est volontaire : cliquer sur la première d'entre elles est la cause classique de l'action sur la mauvaise ligne. La description soumise à l'opérateur inclut les valeurs du formulaire (statut=Expédiée) : l'humain confirme ce qui sera envoyé, pas seulement le nom du bouton.

3. La connexion hors du modèle

files/harness/browser.py (extrait)
def se_connecter(self) -> dict:
    self.page.goto(urljoin(self.base_url, "login"))
    user, password = self._credentials
    self.page.get_by_label("Identifiant", exact=True).fill(user)
    self.page.get_by_label("Mot de passe", exact=True).fill(password)
    self.guard.arm(1)  # le POST de connexion est une action du harness, pas du modèle
    ...

Ce n'est pas suffisant. En écrivant le test, on constate que aria_snapshot() expose la valeur d'un champ type=password rempli :

- textbox "Identifiant": operateur
- textbox "Mot de passe": secret123

Si l'agent appelait lire_page sur un formulaire de connexion rempli (connexion lente, double soumission), le secret partirait dans le contexte, puis dans les traces. Le harness masque donc le secret dans tout arbre renvoyé, et _FORM_VALUES_JS exclut les champs mot de passe des descriptions soumises à l'opérateur :

files/harness/browser.py (extrait de _state)
snap = simplify(self.page.locator("body").aria_snapshot())
# L'arbre ARIA expose la valeur des champs, y compris type=password : on masque le secret.
snap = snap.replace(self._credentials[1], "[masqué]")

Le test test_mot_de_passe_masque_meme_si_la_page_l_affiche vérifie les deux faits : le secret est dans l'arbre brut, il n'est pas dans ce que reçoit le modèle.

Sur une page de fiche, l'agent appelle cliquer(role="button", nom="Enregistrer") et reçoit « 2 éléments button nommés Enregistrer : la cible est ambiguë ». Que faites-vous côté harness ?

La page Commandes affiche 2 000 lignes. lire_page renvoie un arbre tronqué à 8 000 caractères. L'agent doit trouver les commandes d'un client précis. Quelle conception d'outil privilégiez-vous ?

Ce qui casse en production

SymptômeCauseParade
TimeoutError après un clicla page charge en JavaScript après loadattendre un état métier (get_by_role("status")) plutôt qu'un délai fixe
export cliqué, aucun fichierle téléchargement arrive après le retour de l'outilattente après les clics à effet ; page.expect_download() si l'export est lent (marqué ponytail: dans le code)
le modèle invente un nom d'élémentil paraphrase le libellé (« Sauvegarder »)message d'erreur qui renvoie vers lire_page ; exact=True conservé
arbre vide sur une applicationcomposants sans rôles ARIArepli capture_ecran, et alerte dans la trace
un secret apparaît dans trace.jsonll'arbre ARIA contient la valeur d'un champ mot de passemasquage dans _state ; recherche du secret dans les traces en CI

Règle de décision

Exposez des outils étroits dont chaque appel est lisible par une règle : rôle et nom, libellé de champ, chemin relatif. N'exposez jamais d'outil d'exécution de code ou de sélecteur CSS libre à un agent qui manipule des données réelles. Surveillez dans les traces le ratio d'erreurs « aucun élément » : au-delà de quelques pour cent, les libellés de l'application ont changé.

Résultat obtenu

Exécuté le 2026-10-15, sur Chromium headless et le back-office local, sans réseau externe :

$ .venv/bin/python -m pytest -v tests/test_browser.py
tests/test_browser.py::test_connexion_sans_fuite_du_mot_de_passe PASSED
tests/test_browser.py::test_recherche_et_tableau PASSED
tests/test_browser.py::test_modification_refusee_puis_accordee PASSED
tests/test_browser.py::test_filtre_reseau_coupe_un_post_non_confirme PASSED
tests/test_browser.py::test_navigation_externe_bloquee PASSED
tests/test_browser.py::test_export_telecharge PASSED
tests/test_browser.py::test_arbre_simplifie_garde_les_cibles PASSED
tests/test_browser.py::test_mot_de_passe_masque_meme_si_la_page_l_affiche PASSED
tests/test_browser.py::test_redirection_vers_un_domaine_tiers_coupee PASSED
9 passed in 6.62s

Exercice

Ajoutez un argument facultatif dans à cliquer qui restreint la recherche à une région ou à une ligne de tableau nommée, par exemple cliquer(role="link", nom="Voir", dans="CMD-1042").

Voir la solution
def cliquer(self, role: str, nom: str, dans: str = "") -> dict:
    scope = self.page
    if dans:
        scope = self.page.get_by_role("row", name=dans).or_(self.page.get_by_role("region", name=dans))
        if scope.count() != 1:
            raise Blocked(f"Zone {dans!r} introuvable ou ambiguë")
    loc = scope.get_by_role(role, name=nom, exact=True)
    ...

Mettez à jour la déclaration de l'outil dans harness/agent.py ("dans": {"type": "string"}, non requis) et ajoutez la zone à la description soumise à confirmation : l'opérateur doit voir sur quelle ligne porte l'action. Le nom d'une ligne (row "CMD-1042 Coopérative du Val-Morin ...") contient la référence, d'où l'usage d'une correspondance partielle ici.

Je sais faire

  • Concevoir un outil de navigateur dont chaque appel est vérifiable par une règle.
  • Réduire l'arbre ARIA sans perdre de cible cliquable, et mesurer le gain en jetons.
  • Refuser une cible ambiguë au lieu de cliquer sur la première.
  • Garder les secrets de connexion hors du contexte du modèle.
Siguiente