02.12 — Custom types & custom errors#

Ocarina’s shape layer. No logic — types, aliases, exceptions, protocols. What makes the DSL typed without adding runtime logic.

custom_types/#

FileContents
effect.pytype Effect = Callable[[], None] + type Effects = tuple[Effect, ...]
thunk.pytype Thunk[T] = Callable[[], T]
tpom.pyTypeVar TPOM bound POMBase
supports_write.pyProtocol SupportsWrite[T] (write(s: T) -> Any)
built_web_driver.pytype BuiltWebDriver[Driver] = tuple[Driver, Effect]
scenario.pyScenario[Driver] (frozen dataclass)
test_components.pyTestChain, TestSetup, TestTeardown, TestWatchers[Driver]
test_runner.pyTestRunner[Driver] (frozen dataclass)
oc_test.pyTestName, TestScenario[Driver], TestScenarioFragment[Driver]
oc_test_layers.pyTestId, TestResult, TestSuiteResult, TestSuiteResults, TestCampaignResults, TestCycleResults
selenium/built_web_driver.pyBuiltSeleniumWebDriver = BuiltWebDriver[WebDriver]
selenium/oc_test_scenario.pySeleniumTestScenario = TestScenario[WebDriver]
selenium/supported_browsers.pytype SupportedSeleniumBrowser = Literal["chrome", "firefox", "edge", "safari"]
selenium/web_drivers_pool.pytype SeleniumWebDriversPool = WebDriversPool[WebDriver]

All these files are excluded from coverage (pyproject.toml#tool.coverage):

"src/ocarina/custom_types/*",

Reason: no runtime logic to test — these are shapes.

Hierarchy#

# src/ocarina/custom_types/oc_test_layers.py

type TestId = str
type _TestStepsCount = int
type TestResult = Result[Any] | None
type TestSuiteResult = tuple[TestResult, _TestStepsCount, TestId]
type TestSuiteResults = dict[str, TestSuiteResult]                  # {test_name: TestSuiteResult}
type TestCampaignResults = dict[str, TestSuiteResults]              # {suite_name: TestSuiteResults}
type TestCycleResults = dict[str, TestCampaignResults]              # {campaign_name: TestCampaignResults}

Reading:

TestCycleResults
  └── "Dashboard login" (campaign)
      └── "Login happy paths" (suite)
          ├── "Login - without OTP" (test) → (Ok(None), 15, "login_no_otp")
          └── "Login - with OTP"    (test) → (Fail(exc), 8, "login_otp")

Three levels of nesting, indexed by name. (TestResult, steps_count, test_id) is the leaf.

Scenario[Driver], TestRunner[Driver], TestChain#

@dataclass(frozen=True)
class Scenario[Driver]:
    test_chain: TestChain
    setup: TestSetup = field(default=None)
    teardown: TestTeardown = field(default=None)
    watchers: TestWatchers[Driver] | None = field(default=None)


@dataclass(frozen=True)
class TestRunner[Driver]:
    chain_runners: list[ChainRunner[Any]]
    skipped: bool
    setup: Effect | None
    teardown: Effect | None
    watchers: Sequence[Watcher[Driver]] | None


type TestChain = Sequence[ChainRunner[Any]]
type TestSetup = Effect | None
type TestTeardown = Effect | None
type TestWatchers[Driver] = Sequence[Watcher[Driver]] | None

Two dataclasses + 4 type aliases. No methods, just data bags.

custom_errors/#

FileExceptionRaised by
test_framework/no_matching_branch.pyNoMatchingBranchErrormatch_page when no when matches
test_framework/pages.pyPageVerificationErrorConventionally raised by POM.verify when the page isn’t the expected one
test_framework/driver_died.pyDriverDiedErrordriver_healthcheck when the driver stops responding
test_framework/campaigns.pyDuplicateTestNameErrorRaised when two tests within the same campaign share the same name. Subclass of DuplicatesError (itself InvariantViolationError); carries the duplicates sequence for the report.

DriverDiedError#

@final
class DriverDiedError(Exception):
    """Raised when a WebDriver instance dies or becomes unresponsive."""

→ Lets a caller separate “the driver is dead” from “the driver is misbehaving.” Ocarina treats it as transient via project adapters (the project adds DriverDiedError to its transient_errors).

PageVerificationError#

Conventionally raised inside POM.verify:

def verify(self, *, timeout: float | None = None) -> Self:
    try:
        WebDriverWait(self._driver, timeout or get_timeout()).until(...)
    except TimeoutException as exc:
        raise PageVerificationError from exc
    return self

Lets you tell “wrong page” apart from “Selenium error.”

NoMatchingBranchError#

Raised by _match_page_builder when no when matches. See 03-railway/07-match-page-when.md.

A scenario-logic error: the user forgot to cover a case. It should propagate as Fail on the failure rail, not as transient_errors — retrying won’t fix missing coverage.

custom_invariants/testing/#

FileInvariantRaises on
workers.pyvalidate_workers_amountworkers < 1
oc_test_runners_ids.pyvalidate_test_runners_idsDuplicate IDs
oc_test_runners_names.pyvalidate_test_runners_namesDuplicate OR invalid-as-filename names
oc_test_suites_names.pyvalidate_test_suites_namesDuplicate suite names
oc_test_campaigns_names.pyvalidate_campaigns_namesDuplicate campaign names
oc_test_cycles_names.pyvalidate_test_cycle_nameInvalid-as-filename name

All written with FrameworkInvariantValidator.create(...) (see 04-invariants/05-business-vs-framework-validator.md).

SupportsWrite[T]#

from typing import Protocol, TypeVar

T_contra = TypeVar("T_contra", contravariant=True)

class SupportsWrite(Protocol[T_contra]):
    def write(self, s: T_contra, /) -> Any: ...

Minimal protocol: any object with .write(s) qualifies. Lets loggers take a stream: SupportsWrite[str] | None (sys.stdout, sys.stderr, file, mocked BytesIO).

Note: contravariant=True because write is an input operation, hence contravariant in T.
Lines up with Python’s stdlib.