02.05.01 — Test[Driver]

02.05.01 — Test[Driver]#

Fichier source : src/ocarina/dsl/testing/oc_test.py

Signature#

@final
class Test[Driver]:
    def __init__(
        self,
        *,
        name: TestName,
        test_id: str | None = None,
        test_scenario: TestScenario[Driver],
        pre_test_scenarios_fragments: Sequence[TestScenarioFragment[Driver]] | None = None,
        post_test_scenarios_fragments: Sequence[TestScenarioFragment[Driver]] | None = None,
        skipped: bool = False,
    ) -> None:
        if test_id is None:
            test_id = name
        self.name = name
        self.test_id = test_id
        self._test_scenario = test_scenario
        self._pre_test_scenarios_fragments = pre_test_scenarios_fragments or []
        self._post_test_scenarios_fragments = post_test_scenarios_fragments or []
        self._skipped = skipped

Six paramètres :

ParamètreTypeRôle
nameTestName (str)Label humain ; apparaît dans le rapport, et devient le nom du fichier de log (donc soumis à is_valid_filename).
test_idstr | NoneIdentifiant stable pour --only--exclude. Si absent : prend name.
test_scenarioTestScenario[Driver] (alias = Callable[[Driver, ILogger], Scenario[Driver]])Factory qui construit le Scenario au moment de l’exécution.
pre_test_scenarios_fragmentsSequence[TestScenarioFragment[Driver]] | NoneFonctions (driver, logger) -> TestChain exécutées avant le scénario principal.
post_test_scenarios_fragmentsSequence[TestScenarioFragment[Driver]] | NoneFonctions (driver, logger) -> TestChain exécutées après le scénario principal.
skippedboolSi True, le test est enregistré mais pas exécuté.

1. @final#

Pas d’héritage utilisateur. Si l’on veut un test « spécial », on compose via les fragments ou via un scénario, on ne sous-classe pas.

04.01 — Stratégie « dehors comme un utilisateur »

04.01 — Stratégie « dehors comme un utilisateur »#

C’est la posture documentée dès le conftest.py des tests scénarios. Aucun test ne triche en regardant les internes.

tests/scenarios/conftest.py#

ComposantRôle
FakeDriver (dataclass)Driver minimaliste : title, disposed. Aucune méthode Selenium.
make_built_driver()Factory (FakeDriver, dispose), soit la signature attendue par WebDriversPool.
make_pool(max_size=2)WebDriversPool[FakeDriver] prêt à l’emploi.
RecordingPOM(POMBase)POM qui consigne ses appels et peut être configuré pour lever.
acting(pom, step)ActionSuccess[RecordingPOM] complet (failure+success en no-op).
scenario_of("ok", "ok2")TestScenario[FakeDriver] à N steps.
failing_scenario(step="boom", exc=...)Scénario à 1 step qui lève.
make_test(name, scenario=..., test_id=..., skipped=False)Test[FakeDriver]
make_suite(name, tests, pool=..., transient_errors=..., max_retries_per_test=...)TestSuite[FakeDriver]
make_campaign(name, suites, max_workers=1)TestCampaign[FakeDriver]
make_cycle(name="cycle", campaigns=..., smoke=..., mode=...)TestCycle[FakeDriver]
run_chain(runner)Helper qui exécute un ChainRunner et retourne le ActionChain.

FakeDriver#

@dataclass
class FakeDriver:
    title: str = "fake"
    disposed: bool = False

C’est tout. Le cœur d’Ocarina n’appelle aucune API Selenium.

02.10.02 — DriverBuilder[Driver]

02.10.02 — DriverBuilder[Driver]#

Fichier source : src/ocarina/infra/driver_builder.py

Encapsule la gestion du profil (souvent une copie tmp d’un répertoire utilisateur) et produit la paire (driver, dispose) attendue par la pool.

Pourquoi un builder ?#

Quand on construit un driver Selenium avec un profil, il faut :

  1. Copier le profil dans un dossier temporaire (sinon Firefox/Chrome locked sur le profil original).
  2. Lancer le driver en pointant vers le dossier temporaire.
  3. À la fin : driver.quit() puis supprimer le dossier temporaire.

DriverBuilder factorise ces trois étapes :

00.03 — Flux d'exécution global d'une campagne e2e

00.03 — Flux d’exécution global d’une campagne e2e#

Un python -u src/main.py … lancé sur l’une des deux suites (ocarina-example ou ocarina-with-ai-example) déclenche cette chaîne :

USER  ────────────────────────────────────────────────────────────────────
       python -u src/main.py --browser firefox --workers 3 [...]
              │
              ▼
       (1) parse CLI
           CliStoreSingleton.push(create_selenium_auto_cli_store())
              │
              ▼
       (2) build pool
           create_selenium_drivers_pool(max_size=N)
              │
              ▼
       (3) warm-up dépendances externes
           - Redis (ocarina-example)
           - Heroku dyno (ai-example, via curl --retry 6)
              │
              ▼
       (4) bootstrap(
              test_cycle  = create_e2e_test_cycle(drivers_pool),
              run_plugins = lambda results: run_plugins(
                                generate_docx_proof, generate_json_results,
                                exceptions_logger=...),
              post_exec   = pretty_print_results + sys.exit(1) si fail
           )
              │
OCARINA  ─────┼──────────────────────────────────────────────────────────
              │
              ▼
       (5) TestCycle.run_all (saturate_workers=True)
              ├─ smoke_tests_campaigns      [mode : fail-fast | wait-for-all]
              │     └─ TestCampaign.run_all
              │           └─ TestSuite.run (max_workers, saturate_workers)
              │                 └─ ThreadPoolExecutor → TestFlow.run
              │                       └─ pool.acquire() → TestExecutor.execute
              │                             ├─ setup()                  (optionnel)
              │                             ├─ watchers.start()         (daemon threads)
              │                             ├─ chain_runners            (DSL Railway)
              │                             ├─ watchers.stop()
              │                             └─ teardown()               (toujours)
              ├─ campaigns (main)
              │  (skippées si un smoke a fail)
              │
SUT   ────────┼────────────────────────────────────────────────────────────
              │
              ▼
       Selenium WebDriver ⇄ navigateur réel
       parfois : OTP HTTP GET + Redis (côté ocarina-example)
              │
              ▼
       (6) run_plugins(results)
              ├─ generate_docx_proof  (lit l'arbre de logs, fabrique des .docx)
              ├─ generate_json_results (sérialise results en .json)
              ├─ d'autres si déclarés (parallélisés via ThreadPoolExecutor)
              │
              ▼
       (6') post_exec(results)
              ├─ pretty_print_results (ANSI, hiérarchique)
              └─ has_test_cycle_failed → sys.exit(1) le cas échéant

Détail des étapes#

(1) Parse CLI#

create_selenium_auto_cli_store() détecte l’OS via platform.system() :

02.10.03 — Screenshotter[TDriver]

02.10.03 — Screenshotter[TDriver]#

Fichier source : src/ocarina/infra/screenshotter.py

Utilitaire de capture d’écran générique, thread-safe, agnostique du driver via un Protocol, configurable via une dataclass. Supporte le burst.

Protocol#

class ScreenshotDriver(Protocol):
    def save_screenshot(self, path: str) -> bool:
        """Save standard viewport screenshot."""

C’est tout. N’importe quel objet qui a une méthode save_screenshot(path: str) -> bool est utilisable. Selenium WebDriver l’a nativement (driver.save_screenshot), Playwright peut être wrappé, un fake driver en test l’expose trivialement.

ScreenshotterConfig[TDriver]#

@dataclass(frozen=True)
class ScreenshotterConfig[TDriver: ScreenshotDriver]:
    output_dir: Path
    file_ext: str = ".png"
    health_check: HealthCheck[TDriver] | None = None
    save_full_page: SaveFullPageScreenshot[TDriver] | None = None
    default_burst_delay: float = 0.5
    max_filename_retries: int = 500
    uuid_length: int = 8
ChampTypeRôleDefault
output_dirPathRépertoire des screenshots — 
file_extstrExtension (.png, .jpg).png
health_checkHealthCheck[TDriver] | None(driver) -> None qui lève si driver mortNone
save_full_pageSaveFullPageScreenshot[TDriver] | None(driver, path) -> bool pour les screenshots pleine page (Firefox uniquement)None
default_burst_delayfloatDélai entre 2 shots en mode burst0.5s
max_filename_retriesintMax essais pour générer un nom unique500
uuid_lengthintLongueur du suffix UUID dans le nom8

Immutable. On configure une fois et on exporte.

02.11.03 — Auto CLI store + flags + validation

02.11.03 — Auto CLI store + flags + validation#

Fichier source : src/ocarina/opinionated/cli/selenium/create_cli_store.py

Flags#

FlagDefaultTypeValidation
--driver-path""stris_file (sauf --browser safari)
--profile-pathNonestris_none OR is_dir
--browserNone (requis)strchoices argparse (par OS)
--not-headlessFalse (= headless par défaut)bool (store_true)phantom
--workers5intis_positive + is_not_zero
--logger"terminal+file"strchoices=LOGGERS_CHOICES
--wait-timeout10int≤ 60, > 0
--dont-force-delete-tmp-dirsFalsebool (store_true)phantom
--only[]list[str] (nargs="+")phantom + mutex
--exclude[]list[str] (nargs="+")phantom + mutex
_DEFAULT_WORKERS_AMOUNT = 5
_DEFAULT_LOGGER: SupportedLogger = "terminal+file"
_DEFAULT_BROWSER_AUTOMATION_TIMEOUT = 10
_MAX_BROWSER_AUTOMATION_TIMEOUT = 60

Literal[...]#

type SeleniumCliStoreKeys = Literal[
    "driver_path", "profile_path", "browser", "headless", "workers",
    "logger", "wait_timeout", "force_delete_tmp_dirs", "only", "exclude",
]

Ces clés sont l’unique vocabulaire du store. Les call-sites les utilisent : store.get("workers"), store.get("browser"), etc.

10.04 — Workflows ocarina-with-ai-example

10.04 — Workflows ocarina-with-ai-example#

Voir aussi ../08-ai-example/09-ci-matrix.md

Vue d’ensemble#

WorkflowTriggerOSEffetNote
ai_proof_ci.ymlpush main, PR, dispatchubuntu × py 3.14ruff format src/ --checkruff check src/mypy src/Gate PR, rapide
ai_proof_e2e.ymldispatchubuntu × matrice firefox+chromewarm-up Heroku + run + sed filter ChromeDriver C++ stacks + uploadManuel uniquement

Différences avec ocarina-example#

Aspectocarina-exampleocarina-with-ai-example
Matrice browserFirefox seul (e2e.yml)Firefox + Chrome (parallélisés)
SUTIgoristan (GitHub Pages)CURA (Heroku eco-dyno)
Pre-runwarm-up Redis clientwarm-up Heroku dyno
Filtre outputaucunsed filtre les stack frames C++ ChromeDriver
WAIT_TIMEOUTdefault 1015 (compense les latences Heroku)

warm-up Heroku#

curl -sf --retry 6 --retry-delay 5 --retry-all-errors \
  --max-time 30 https://katalon-demo-cura.herokuapp.com/ > /dev/null
echo "Dyno is warm"

CURA runs on a Heroku dyno that sleeps after inactivity. Tests hitting a cold dyno produce timeouts (false fails) and slow confirmations (false passes on gap tests). Wake it explicitly before any browser opens.

01.05 — Posture politique du projet

01.05 — Posture politique du projet#

Le Holy Book ne sépare pas la philosophie de la politique du dépôt lui-même. Cinq engagements opérationnels en découlent.

1. Incorruptibilité#

« Transmettre Ocarina, c’est transmettre une voiture dont j’ai fait tout l’entretien moi-même, pour moi-même, donc très précautionneusement. En revanche, elle est et restera transmise telle quelle. C’est ma voiture. »

« J’y ai canalisé toute ma colère, pour y mettre tout mon amour. »

02.10.05 — Adapters Selenium

02.10.05 — Adapters Selenium#

Dossier source : src/ocarina/infra/selenium/

Tout ce qui matérialise les abstractions (POMBase, WebDriversPool, Screenshotter) en version Selenium. Vit hors du DSL pur — et depuis la 1.1.3 il a un jumeau : src/ocarina/infra/playwright/ livre exactement les mêmes contrats en version Playwright. Changer de backend, c’est désormais choisir un dossier, pas l’écrire.

Fichiers du dossier#

FichierRôle
create_driver.py_build_firefox, _build_chrome, _build_edge, _build_safari + dispatch
create_drivers_pool.pycreate_selenium_drivers_pool (utilise DriverBuilder + WebDriversPool)
create_screenshotter.pycreate_selenium_screenshotter + _selenium_save_full_page (Firefox-only)
driver_healthcheck.pydriver_healthcheck(driver) — ping driver.title
mixins.pySeleniumTitleMixin

create_driver.py#

def _build_firefox(*, profile_path, driver_path, headless, wait_timeout) -> WebDriver:
    service = FirefoxService(executable_path=driver_path)
    options = FirefoxOptions()
    if headless:
        options.add_argument("-headless")
    if profile_path:
        options.profile = FirefoxProfile(profile_path)
    driver = Firefox(service=service, options=options)
    driver.implicitly_wait(wait_timeout)
    return driver


def _build_chrome(*, profile_path, driver_path, headless, wait_timeout) -> WebDriver:
    service = ChromeService(executable_path=driver_path)
    options = ChromeOptions()
    if headless:
        options.add_argument("--headless=new")
    if profile_path:
        options.add_argument(f"--user-data-dir={profile_path}")
    driver = Chrome(service=service, options=options)
    driver.implicitly_wait(wait_timeout)
    return driver


def _build_edge(*, profile_path, driver_path, headless, wait_timeout) -> WebDriver:
    # … identique à Chrome avec EdgeService/EdgeOptions/Edge …


def _build_safari(*, profile_path, driver_path, headless, wait_timeout) -> WebDriver:
    # … Safari ne supporte ni driver_path (utilise safaridriver natif macOS),
    #   ni profile_path (pas de profile custom), ni headless …

1. implicitly_wait défini une seule fois#

Chaque builder appelle driver.implicitly_wait(wait_timeout) une fois. Donc, tout find_element qui ne trouve pas l’élément immédiatement attend jusqu’à wait_timeout secondes avant de lever NoSuchElementException.

02.07 — Watcher[Driver]

02.07 — Watcher[Driver]#

Fichier source : src/ocarina/dsl/testing/watcher.py

Observateur parallélisé qui tourne en daemon thread aux côtés de la test_chain. Conçu pour détecter les frictions imprévisibles et sans impact direct sur le scénario qui se produisent pendant que le scénario tourne : toasts d’erreur aléatoires, validations parasites, popups inattendus.

Le problème#

Citation du Holy Book (handling-flakiness) :

Plus surprenant encore : des applications affichant des toasts d’erreur sans raison apparente, ou des formulaires signalant des erreurs de validation sur des saisies pourtant correctes, sans pour autant bloquer le parcours.