02.05.04 — TestSuite[Driver] — moteur parallélisé

02.05.04 — TestSuite[Driver] — moteur parallélisé#

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

Responsabilité unique : exécuter une séquence de Test en parallèle, gérer la saturation, le filtrage par IDs, et la validation des invariants pré-exécution.

Constructeur#

def __init__(
    self,
    *,
    name: str,
    tests: Sequence[Test[Driver]],
    create_logger: Thunk[ILogger],
    drivers_pool: WebDriversPool[Driver],
    take_screenshot: ITakeScreenshot[Driver],
    act_counter: ActCounter | None = None,
    transient_errors: tuple[type[Exception], ...] = (),
    copy_indicator: str = "COPY",
    put_space_after_copy_indicator: bool = True,
    max_retries_per_test: int | None = None,
    autoscreen_on_fail: bool = False,
    saturate_workers: bool | None = None,
    only_ids: Iterable[str] = (),
    exclude_ids: Iterable[str] = (),
) -> None:
    self._guards_on_invoke(tests)
    # ... assignations ...
    self._tests = filter_tests_by_ids(
        tests,
        only=only_ids,
        exclude=exclude_ids,
        logger=create_logger().set_prefix(lambda: f"{self.name}: filtering tests..."),
    )
ParamètreTypeRôleDéfaut
namestrNom de la suite (apparaît dans logs & rapports) — 
testsSequence[Test[Driver]]Liste des tests à exécuter — 
create_loggerThunk[ILogger]Factory de logger — 
drivers_poolWebDriversPool[Driver]Pool partagée — 
take_screenshotITakeScreenshot[Driver]Callable (driver, logger, category) -> None — 
act_counterActCounter | NoneSi None → ThreadsBasedActCounter()None
transient_errorstuple[type[Exception], ...]Exceptions qui déclenchent un retry()
copy_indicatorstrPréfixe pour les tests clonés en saturation"COPY"
put_space_after_copy_indicatorbool[COPY 1] (True) vs [COPY1] (False)True
max_retries_per_testint | NoneNombre maximum de retentatives d’un test potentiellement flakyNone → _DEFAULT_MAX_RETRIES_PER_TEST (8)
autoscreen_on_failboolCapture automatiquement un ou des screenshots (rafale) en cas d’échecFalse
saturate_workersbool | NoneClonage des tests pour saturer tous les workers si la pool est suffisamment grosseNone → cascade : suite > campaign > bootstrap
only_idsIterable[str]Filtre --only()
exclude_idsIterable[str]Filtre --exclude()

Garde-fous#

Au moment du __init__#

def _guards_on_invoke(self, tests: Sequence[Test[Driver]]) -> None:
    validate_test_runners_ids(tests=tests, name="tests").execute().raise_if_invalid()

→ Tous les test_id doivent être uniques. Sinon : AggregateInvariantViolationError. Levé dès la construction, avant la moindre tentative d’exécution. Si l’utilisateur a dupliqué un test_id par erreur, il l’apprend immédiatement.

08.04 — Stratégie de test

08.04 — Stratégie de test#

Six types de tests, six rôles distincts. Documentés dans CURA_TEST_STRATEGY.md §3.

Types de test#

TypeStatut attenduSource d’autorité
Happy path (cas passant)PASSParcours nominaux décrits d’après les SFD
Unhappy path (cas non passant)PASS (le test passe quand l’application refuse de “laisser passer”)Validations existantes correctes
Edge case / boundary (tests aux limites)PASS ou FAILSelon le test, souvent accompagné d’une note ajoutée sur les SFD
Business logic vulnerability / gap test (tests fonctionnels pour essayer de faire “tomber” le produit et d’identifier des gaps)FAIL intentionnelComportements qu’un système devrait empêcher mais que CURA n’empêche pas
Exploratory / observed-behaviour (exploratoire)PASSDocumente le comportement actuel quand la spec ne le couvre pas
Permanent security regression fixture (non-régression)PASS (= la sécurité tient)Vérifie qu’un correctif de sécurité tient (correctifs qui resteront définitivement dans le patrimoine de test)

Happy path#

Exercises the nominal flow with valid inputs and an authenticated user. Verifies that the system produces the correct output (confirmation, history entry, etc.).

02.05.05 — Saturation des workers

02.05.05 — Saturation des workers#

Mécanisme propre à Ocarina : si une suite a moins de tests que de workers disponibles, on clone aléatoirement des tests jusqu’à atteindre le nombre de workers. Les copies sont renommées [COPY 1] <name>, [COPY 2] <name>, etc.

Pourquoi#

Le Holy Book formalise la motivation dans le chapitre « Premiers obstacles du monde réel » :

Son option saturate_workers permet de forcer du clonage aléatoire de tests à l’intérieur d’une suite.

Chapitre 02.05 — Orchestration

Chapitre 02.05 — Orchestration#

Chaîne Test → TestSuite → TestCampaign → TestCycle : comment chaque niveau s’articule, qui gère la parallélisation, qui gère les rejeux, qui décide du skip, et où vivent les invariants pré-exécution.

Plan#

#FichierSujet
0101-test.mdLa classe Test[Driver] — spawn, fragments pre/post, skip.
0202-test-executor.mdTestExecutor — exécution d’une tentative, ordre : setup → watchers.start → chain → watchers.stop → teardown.
0303-test-flow-retries.mdTestFlow — boucle de rejeu (1+max_retries), backoff linéaire, gestion des setup-failures.
0404-test-suite.mdTestSuite — parallélisation avec ThreadPoolExecutor, filtrage IDs, garde-fous.
0505-saturation.mdSaturation des workers : clonage aléatoire [COPY N]. Pourquoi et comment.
0606-test-campaign.mdTestCampaign — séquence de suites, campaign_has_failed.
0707-test-cycle-modes.mdTestCycle — smoke + main, modes fail-fast vs wait-for-all, has_test_cycle_failed.
0808-filter-tests-by-ids.mdfilter_tests_by_ids — --only/--exclude, mutex, ignore les unknown IDs.

Schéma#

            ┌────────────────────────────────────────────────┐
            │                  TestCycle                     │
            │                                                │
            │  ┌──────────────────────────────────────────┐  │
            │  │ smoke_tests_campaigns                    │  │ ◄── 1er, gate
            │  │  └─ TestCampaign                         │  │     mode : fail-fast |
            │  │      └─ TestSuite                        │  │            wait-for-all
            │  │          └─ Test                         │  │
            │  └──────────────────────────────────────────┘  │
            │                                                │
            │  ┌──────────────────────────────────────────┐  │
            │  │ campaigns (main)                         │  │ ◄── skippées si
            │  │  └─ TestCampaign                         │  │     un smoke fail
            │  │      └─ TestSuite                        │  │
            │  │          └─ Test                         │  │
            │  └──────────────────────────────────────────┘  │
            └────────────────────────────────────────────────┘

Qui fait quoi ?#

NiveauResponsabilité uniqueConcurrenceHors périmètre
TestMétadonnées + spawn(driver, logger)aucuneexécution
TestExecutorUne tentativeaucunerejeu, acquisition de driver
TestFlowBoucle de rejeuaucuneparallélisation, agrégation
TestSuiteParallélisation + saturation + filtrage IDsN threadsséquence inter-suites
TestCampaignSéquence de suitessuite-levelsmoke vs main
TestCycleSmoke + main + modecampaign-levelbootstrap / plugins post-exec

Cette séparation stricte est délibérée : TestExecutor ne sait rien des retries, TestFlow ne sait rien de la concurrence, TestSuite ne sait rien de l’agrégation campaign-level. Chaque classe a un seul axe de responsabilité.

02.05.06 — TestCampaign[Driver]

02.05.06 — TestCampaign[Driver]#

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

Responsabilité unique : exécuter une séquence de suites dans l’ordre, partager une config de workers, et déclarer campaign_has_failed.

Code#

class TestCampaign[Driver]:
    def __init__(
        self,
        *,
        name: str,
        suites: Sequence[TestSuite[Driver]],
        max_workers: int,
        saturate_workers: bool | None = None,
    ) -> None:
        validate_test_suites_names(suites=suites, name="suites").execute().raise_if_invalid()

        self.name = name
        self._suites = suites
        self._results: TestCampaignResults = {}
        self._max_workers = max_workers
        self._saturate_workers = saturate_workers

        for suite in self._suites:
            suite._campaign_name = self.name

    def run_all(
        self, *, skip_all: bool = False, saturate_workers: bool = True
    ) -> TestCampaignResults:
        self._results.clear()

        resolved_saturate_workers = (
            self._saturate_workers
            if self._saturate_workers is not None
            else saturate_workers
        )

        if skip_all:
            for suite in self._suites:
                self._results[suite.name] = {
                    name: (None, -1, test_id)
                    for name, test_id in suite.test_names_and_ids
                }
            return self._results

        for suite in self._suites:
            self._results[suite.name] = suite.run(
                max_workers=self._max_workers,
                saturate_workers=resolved_saturate_workers,
            )

        return self._results


def campaign_has_failed(results: TestCampaignResults) -> bool:
    return any(
        is_test_result_fail(outcome)
        for campaign_results in results.values()
        for outcome, _, _ in campaign_results.values()
    )

Anatomie#

Garde-fou au constructeur#

validate_test_suites_names(suites=suites, name="suites").execute().raise_if_invalid()

→ Les noms de suites de la campagne doivent être uniques — de façon insensible à la casse depuis 1.1.10 (NFC + casefold). Levé dès la construction.

06.07 — Flux de coordination OTP + l'anti-précision volontaire

06.07 — Flux de coordination OTP + l’imprécision volontaire#

La raison d’être du backend : permettre à N workers parallèles d’Ocarina de récupérer le bon OTP pour leur user, même quand plusieurs OTP sont générés.

Flux#

   ┌───────────────────────────────────────────────────────────────────┐
   │               Worker (parmi --workers 3 d'Ocarina)                │
   └───────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium ouvre la page de connexion au Dashboard                  │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Acquisition du lock distribué Redis (OTP_SEND_LOCK_KEY)           │
   │   → seul ce worker peut cliquer "Send OTP" pendant ACQ            │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ min_utc_date = datetime.now(UTC)                                  │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Cache L1 : enregistre min_utc_date + username dans le cache       │
   │   (clés réservées par reserve_free_cache_key)                     │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium tape username + password + cocher OTP + click "REQ OTP"  │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ IGORISTAN UI : fetch /api/otp?_user=<username>                    │
   │   (x-api-key tapé par Selenium dans l'UI)                         │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ TESTS-WORKERS /api/otp :                                          │
   │   generate(secret) → otpCode                                      │
   │   createdAt = floor(now/1000)*1000  ← amputation ms               │
   │   event = { _user, otpCode, createdAt, expiresAt, ... }           │
   │   redis.set("otp:<now>:<uuid>", JSON, EX 360)                     │
   │   return event                                                    │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ IGORISTAN UI : affiche écran OTP                                  │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Release du lock Redis OTP_SEND_LOCK_KEY                           │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium : retrieve_dashboard_otp_code(min_utc_date, _user)       │
   │   ↓                                                               │
   │   GET /api/otp-history  (x-api-key = IGOR_API_KEY)                │
   │   ↓                                                               │
   │   TESTS-WORKERS : SCAN otp:* + MGET → all events                  │
   │   ↓                                                               │
   │   filtre côté client par _user                                    │
   │   filtre createdAt >= min_utc_date - 1s                           │
   │   tri ASC sur createdAt                                           │
   │   return first.otpCode                                            │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium tape l'OTP dans l'UI Igoristan                           │
   │   → AUTHENTICATED_WITH_MFA                                        │
   └───────────────────────────────────────────────────────────────────┘

Races conditions#

  1. Worker A : min_utc_date_A = 13:27:53.123, génère OTP_A à 13:27:53.250.
  2. Worker B : min_utc_date_B = 13:27:53.130, génère OTP_B à 13:27:53.470.

Sans coordination, A pourrait récupérer OTPB (au lieu de OTP_A) parce que les timestamps sont _très proches (et tronqués à la seconde près).