07.07 — HumanizedDriver#

Fichier source : src/lib/ext/selenium/humanize/proxy.py

Proxy pattern : wrappe un WebDriver Selenium et intercepte uniquement find_element* pour retourner des WebElement qui font des send_keys humanisés (frappe lente avec typos, hésitations, corrections).

Code#

class HumanizedDriver(WebDriver):
    def __init__(self, driver: WebDriver, **keyboard_config: Unpack[KeyboardConfig]) -> None:
        object.__init__(self)
        self._driver = driver
        self._config = keyboard_config

    def find_element(self, by: str | RelativeBy = "id", value: str | None = None) -> _HumanizedWebElement:
        element = self._driver.find_element(by, value)
        return _HumanizedWebElement(element, self._config)

    def find_elements(self, by: str | RelativeBy = "id", value: str | None = None) -> list[WebElement]:
        elements = self._driver.find_elements(by, value)
        return [_HumanizedWebElement(el, self._config) for el in elements]

    def __getattr__(self, name: str):
        return getattr(self._driver, name)

1. Hérite de WebDriver#

class HumanizedDriver(WebDriver):

HumanizedDriver passe le type check isinstance(x, WebDriver).

2. object.__init__(self) au lieu de super().__init__()#

def __init__(self, driver: WebDriver, **keyboard_config) -> None:
    object.__init__(self)
    ...

C’est un hack. WebDriver.__init__ exige plein de paramètres (service, options, etc.). On bypass via object.__init__(self) pour éviter de devoir passer ces paramètres. L’isinstance check passe quand même.

3. Intercepte uniquement find_element et find_elements#

Tous les autres appels au driver passent par __getattr__. C’est l’unique surcharge :

def find_element(self, ...) -> _HumanizedWebElement:
    element = self._driver.find_element(by, value)
    return _HumanizedWebElement(element, self._config)

→ On récupère l’élément réel, on l’enrobe dans _HumanizedWebElement.

4. __getattr__ pour tout le reste#

def __getattr__(self, name: str):
    return getattr(self._driver, name)

Si on appelle humanized_driver.current_url, humanized_driver.title, humanized_driver.execute_script(...), etc., Python délègue automatiquement au self._driver réel via __getattr__.

5. **keyboard_config: Unpack[KeyboardConfig]#

class KeyboardConfig(TypedDict):
    wpm: float
    typo_rate: float
    hesitation_rate: float
    burst_rate: float
    late_correction_rate: float


def __init__(self, driver: WebDriver, **keyboard_config: Unpack[KeyboardConfig]) -> None:
    ...

Unpack[KeyboardConfig] (PEP 692) permet de typer les **kwargs comme un TypedDict. Le checker vérifie que wpm=125, typo_rate=0.14, ... sont les bonnes clés et bons types.

_HumanizedWebElement#

class _HumanizedWebElement(WebElement):
    def __init__(self, element: WebElement, keyboard_config: KeyboardConfig) -> None:
        self._element = element
        self._config = keyboard_config

    def send_keys(self, *value: str | int | None) -> None:
        humanized_send_keys_with_config(self._element, *value, **self._config)

    def __getattr__(self, name: str):
        return getattr(self._element, name)

Même pattern : intercepte send_keys, délègue le reste.

humanized_send_keys_with_config#

ParamètreEffet
wpm=125Délai entre frappes ~ 480ms / mot, donc ~50ms/char
typo_rate=0.1414% de chance de taper la mauvaise touche
hesitation_rate=0.022% de chance d’une pause longue entre frappes
burst_rate=0.3535% de chance de taper plusieurs lettres en burst rapide
late_correction_rate=0.6Si typo : 60% de chance de s’en rendre compte « plus tard », de tout effacer jusqu’à la typo et reprendre la saisie

Pourquoi humaniser ?#

  • Le ChaoticForm de l’Igoristan affiche des toasts d’erreurs aléatoires : il faut prendre son temps dessus. Si de nouveaux bugs du même genre apparaissent, un HumanizedDriver les détecte mieux que de gros sleep posés « le temps d’attendre un toast ». On est vigilant sur ce formulaire et on y consacre davantage d’effort de test parce qu’on sait qu’il a des comportements indésirables, difficiles à reproduire en testant « juste » le parcours rapidement.
  • Tester comme un humain réel : un formulaire qu’un humain remplit n’est pas testé avec un humain qui tape 10 000 mots/minute.
  • Faire émerger les bugs de timing : si l’UI a une race condition (validation déclenchée trop tôt, etc.), elle apparaît avec une saisie humaine, pas avec une saisie ultra-rapide.

Usage côté scénario#

test_send_chaotic_form = create_selenium_test(
    name="Send the chaotic form",
    test_scenario=lambda driver, logger: Scenario(
        test_chain=_send_chaotic_form(
            HumanizedDriver(
                driver,
                wpm=125,
                typo_rate=0.14,
                hesitation_rate=0.02,
                burst_rate=0.35,
                late_correction_rate=0.6,
            ),
            logger,
        ),
        watchers=[create_selenium_watcher(callback=catch_me_if_you_can_cb, name="catch-me-if-you-can", poll_interval=0.8)],
    ),
)

HumanizedDriver est passé au scénario ; le scénario le passe aux POMs ; les POMs font self._driver.find_element(...).send_keys(...) ; chaque send_keys est humanisé.

L’alternative (rejetée) : monkey-patch#

On aurait pu monkey-patcher WebElement.send_keys au niveau global.

Refusé, parce que :

  • Effet global = effets de bord imprévisibles dans d’autres tests.
  • Pas typé.
  • Pas activable/désactivable par test.

Le proxy est opt-in par scénario, c’est la bonne granularité.
L’un de mes mentors disait : “La bonne abstraction, au bon moment.”