02.03.06 — drive_page

02.03.06 — drive_page#

Source file: src/ocarina/opinionated/dsl/drive_page.py

Code#

def drive_page(
    first: ActionSuccess[TPOM], *rest: ActionSuccess[TPOM]
) -> ChainRunner[TPOM]:
    return chain_actions(first, *rest)

Literally chain_actions.

Why this alias?#

1. Semantics: “I’m taking control of a page”#

Holy Book quote (“First scenarios”):

drive_page expresses the fact that you are taking control of one page. Any transition becomes explicit through the opening of a new drive_page.

return [
    drive_page(
        act(on_homepage, open_homepage)...,
        act(on_homepage, verify_homepage)...,
        act(on_homepage, click_cta)...,
    ),                                          # ⬅ closing control of the homepage
    drive_page(                                 # ⬅ opening control of the next page
        act(on_target_page, verify_target_page)...,
    ),
]

2. Discipline#

Mixing acts from two different POMs inside one drive_page is a mypy error (see 02-action-chain-states.md, “narrowing via act()” section). The semantics get enforced concretely.

02.04.06 — Invariant error hierarchy

02.04.06 — Invariant error hierarchy#

Source file: src/ocarina/dsl/invariants/errors.py

InvariantViolationError (base — subclass of Exception)
├── DuplicatesError                  (raised by `has_unique_elements`)
└── AggregateInvariantViolationError (raised by `_ValidationResult.raise_if_invalid`)

InvariantViolationError#

class InvariantViolationError(Exception):
    """Base exception raised when an invariant is violated."""

Bare Exception subclass, no extra logic. Every predicate raises this (or a subclass).

try:
    validate(value).assert_that(predicate).execute().raise_if_invalid()
except InvariantViolationError as e:
    logger.error(f"Validation failed: {e}")

… catches every violation with a single clause.

DuplicatesError#

class DuplicatesError(InvariantViolationError):
    def __init__(self, duplicates: Sequence[Any], message: str | None = None) -> None:
        self.duplicates = duplicates
        msg = message or "Duplicate elements detected:\n" + "\n".join(
            f" - {d}" for d in duplicates
        )
        super().__init__(msg)
  • Stashes the duplicate list in .duplicates.
  • Default message: “Duplicate elements detected:\n - apple\n - banana”.
  • Accepts a custom message=.

Raised by has_unique_elements.

02.05.06 — TestCampaign[Driver]

02.05.06 — TestCampaign[Driver]#

Source file: src/ocarina/dsl/testing/oc_test_campaign.py

One job: run a sequence of suites in order, share a workers config, and declare 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()
    )

Anatomy#

Constructor guard#

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

→ Suite names inside a campaign must be unique — case-insensitively since 1.1.10 (NFC + casefold). Raised at construction.

02.11.06 — bootstrap + run_plugins

02.11.06 — bootstrap + run_plugins#

Source file: src/ocarina/opinionated/launcher/bootstrap.py

Entry point of an Ocarina project.
Three steps: cycle.run_all → run_plugins → post_exec.

Code#

def bootstrap[T](
    *,
    test_cycle: TestCycle[T],
    run_plugins: Callable[[TestCycleResults], None],
    post_exec: Callable[[TestCycleResults], None] | None = None,
    saturate_workers: bool = True,
) -> None:
    results = test_cycle.run_all(saturate_workers=saturate_workers)
    run_plugins(results)
    if post_exec:
        post_exec(results)

Order#

1. cycle.run_all()  →  results
2. run_plugins(results)
3. post_exec(results) (optional)
StepEffectWhy this order
1All tests runWe need results for the rest
2Plugins fire (DOCX, JSON, etc.)Must run before post_exec since it can take generation time
3Post-exec (pretty_print + sys.exit)Last thing — after this, the process is dead

Typing#

run_plugins: Callable[[TestCycleResults], None]
post_exec: Callable[[TestCycleResults], None] | None = None
  • Both receive the results as an argument:

02.03.07 — match_page / when

02.03.07 — match_page / when#

Source file: src/ocarina/dsl/testing_with_railway/match_page.py

Bolted onto the framework after the fact. The Holy Book puts it bluntly: “match_page and when were added after the fact; the Igoristan was so random that the use case forced itself onto us. Their implementation was simple, proof of the grammar’s flexibility: other analogous structures could follow just as easily.

The problem#

Some pages render differently:

  • Cookies banner showing or not.
  • A/B test serving two UI variants.
  • Degraded mode (maintenance page) instead of the real page.
  • Anti-bot captcha.

The scenario needs to branch without breaking the ROP chain.

02.05.07 — TestCycle[Driver] + modes

02.05.07 — TestCycle[Driver] + modes#

Source file: src/ocarina/dsl/testing/oc_test_cycle.py

One job: orchestrate smoke then main campaigns, and apply a smoke-failure-handling mode.

Two modes#

type Mode = Literal[
    "fail-fast-on-first-smoke-campaigns-sequence-fail",
    "wait-for-all-smoke-tests",
]
ModeBehavior
fail-fast-on-first-smoke-campaigns-sequence-fail (default)First smoke campaign that fails → following ones get skipped.
wait-for-all-smoke-testsEvery smoke campaign runs. If any fails, the main campaigns get skipped.
CaseMode
Dependency-style smoke (e.g. “login” then “dashboard accessible”)fail-fast (login dies → dashboard pointless)
Parallel smoke (e.g. “homepage” + “API ping”)wait-for-all (we want to see both)

fail-fast is the default — it’s the common case.

02.05.08 — filter_tests_by_ids

02.05.08 — filter_tests_by_ids#

Source file: src/ocarina/dsl/testing/filter_tests_by_ids.py

Called by TestSuite.__init__ to apply the CLI flags --only <ids> and --exclude <ids>. Mutually exclusive — can’t pass both.

Code#

def filter_tests_by_ids[Driver](
    tests: Sequence[Test[Driver]],
    *,
    only: Iterable[str] = (),
    exclude: Iterable[str] = (),
    logger: ILogger,
) -> Sequence[Test[Driver]]:
    only_set = set(only)
    exclude_set = set(exclude)

    if only_set and exclude_set:
        raise ValueError("--only and --exclude cannot be used together")

    known_ids = {test.test_id for test in tests}

    if only_set:
        matched = only_set & known_ids
        if matched:
            joined = ", ".join(sorted(matched))
            logger.info(f"--only: matched test IDs: {joined}")
        return [test for test in tests if test.test_id in only_set]

    if exclude_set:
        matched = exclude_set & known_ids
        if matched:
            joined = ", ".join(sorted(matched))
            logger.info(f"--exclude: matched test IDs: {joined}")
        return [test for test in tests if test.test_id not in exclude_set]

    return tests

Four guarantees#

1. Mutex --only / --exclude#

if only_set and exclude_set:
    raise ValueError("--only and --exclude cannot be used together")

Passing both is a runtime error (not a mypy one). Semantically nonsense: “keep only A, B” AND “exclude C, D”? At the CLI level, doesn’t make sense. The mutex kills ambiguity.

Chapter 02.10 — Infrastructure

Chapter 02.10 — Infrastructure#

Everything that touches external resources: driver pool, builders, screenshotters, act counter, and the Selenium adapters that implement them. Since 1.1.3, a parallel infra/playwright/ ships the same contracts in Playwright flavor — same pool, same builder, same ports.

Plan#

#FileSubject
0101-drivers-pool.mdWebDriversPool[Driver]: Semaphore, Queue, warmup with watchdog, shutdown.
0202-driver-builder.mdDriverBuilder[Driver]: profile and tmp dirs handling.
0303-screenshotter.mdScreenshotter[TDriver] + ScreenshotterConfig: burst, healthcheck, threadsafe.
0404-act-counter.mdActCounter + ThreadsBasedActCounter (thread-local).
0505-selenium-adapters.mdcreate_driver (Firefox/Chrome/Edge/Safari), create_drivers_pool, create_screenshotter, driver_healthcheck, SeleniumTitleMixin.
0606-playwright-actor.mdThe PlaywrightDriver actor: owner thread, marshalling through submit, the call_timeout liveness ceiling, cross-thread warmup, observe-only Watcher.

BuiltWebDriver[Driver]#

type BuiltWebDriver[Driver] = tuple[Driver, Effect]

The exchange unit between the builder and the pool: a (driver, dispose) tuple. dispose is an Effect (no argument, no return) that knows how to clean up that exact driver (driver.quit() + removal of the tmp profile if needed).

Chapter 02.11 — opinionated/ layer

Chapter 02.11 — opinionated/ layer#

Everything that’s opt-in: a picky user can swap any of it. CLI, loggers, report plugins, bootstrap, drive_page alias. This layer is what you see on the surface, not the core — the turnkey version Ocarina ships so a typical project doesn’t have to reinvent it.

Plan#

#FileSubject
0101-cli-builder.mdCliBuilder, CliArg, _SilentArgumentParser.
0202-cli-store-phantoms.mdCliStore[TKeys], _CliField[T], phantom_validate.
0303-selenium-cli.mdcreate_selenium_auto_cli_store + flags + validation.
0404-loggers.mdILogger implementations: PrintLogger, FileLogger, PrintAndFileLogger, MutedLogger, create_matching_logger.
0505-plugins-reports.mdpretty_print_results, results_to_json, generate_docx_proof, timing.
0606-bootstrap-launcher.mdbootstrap + run_plugins (parallel).

Opt-out / opt-in#

Without opinionated/With opinionated/
Write your CLI from scratchcreate_selenium_auto_cli_store() hands it to you
Write your loggercreate_matching_logger("terminal+file") hands it to you
Write your reportspretty_print_results, generate_json_results, generate_docx_proof
Write your main()bootstrap(test_cycle, run_plugins, post_exec)

Alternatives#

ComponentReplaceable by…
CliBuilder / CliStoreAny argparse-like lib (click, typer, fire)
PrintLogger / FileLoggerAny ILogger implementation
pretty_print_resultsAny (TestCycleResults) -> None function
generate_docx_proofSame
bootstrapAny () -> None function that calls TestCycle.run_all + post-processes
drive_pagechain_actions directly, or a project alias (drive_section, drive_widget)