Add reboot.bdd: write Reboot tests in Gherkin with pytest-bdd - #149
Draft
benh wants to merge 37 commits into
Draft
Add reboot.bdd: write Reboot tests in Gherkin with pytest-bdd#149benh wants to merge 37 commits into
reboot.bdd: write Reboot tests in Gherkin with pytest-bdd#149benh wants to merge 37 commits into
Conversation
Current Aviator status
This pull request is currently open (not queued). How to mergeTo merge this PR, comment
See the real-time status of this PR on the
Aviator webapp.
Use the Aviator Chrome Extension
to see the status of your PR within GitHub.
|
`start()` creates a `monitor_event_loop()` task but `stop()` never cancelled it. Tests based on `IsolatedAsyncioTestCase` hide the leak because each test's event loop closes right after `stop()`, but on a long-lived event loop (as `reboot.bdd` uses) every harness left a pending task behind that warned at garbage collection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
benh
force-pushed
the
pytest-bdd
branch
25 times, most recently
from
September 2, 2026 03:29
9aa02e5 to
28ca315
Compare
Developers write Gherkin scenarios against built-in steps, e.g.:
Given the application is up
And an `Account` for "alice" gets created via `open` with
`initial_balance=100`
When the `Account` for "alice" gets a `deposit` with `amount=50`
Then `balance` on the `Account` for "alice" has
`balance=150`
A test module brings in the built-in steps and the fixtures they run
on with `from reboot.bdd.steps import *` and defines an `application`
fixture returning the `Application` under test. Each scenario runs
against a fresh started `Reboot` harness on its own event loop, one
loop per scenario the way one application runs on one event loop
under `rbt dev run` and `rbt serve`. Each step's call runs on a fresh
`ExternalContext`, the way each external call in production arrives
with its own, unless the scenario creates one to share via
'Given a shared context'.
Custom steps may be `async def`: the `reboot.bdd`
`given`/`when`/`then` decorators run them on the scenario's event
loop, the same loop the harness and the built-in steps run on, which
is what lets `reboot.bdd` work under plain pytest without
`pytest-asyncio`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
`reboot.bdd` resolves state types and calls methods the same way for proto and pydantic codegen, since both come from the same template; this pins the pydantic path with its own `Account` mirroring `tests/reboot/bdd/accounts.feature`, plus a custom `async def` step that calls through `World.call()` instead of the generated code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
'Given I am "alice"' mints a valid test token for that user ID,
through the application's own OAuth server the way
`Reboot.create_external_context_as` does, and puts it on every
context created from then on, so a servicer sees
`context.auth.user_id`; 'the bearer token is "..."' instead sets a
raw token, the way an application with its own token scheme, e.g.
an admin key, needs. Both work under Given and When, so a scenario
switches users mid-flight:
Given I am "alice"
...
When I am "bob"
Then `whoami` on the `Account` for "joint" has `user_id="bob"`
Saying who you are raises once 'Given a shared context' has run,
because the shared context keeps the token it was created with; say
who you are first. The state-ID resolver generalizes to
`_maybe_saved` so user IDs and tokens also recall '$name' saves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
'`balance` on the `Account` for "alice" eventually has `balance=150` within 30 seconds' holds a reactive read open, running its assertions, the full asserting grammar, equalities and predicates alike, against each response the reader serves, and returns on the first response that satisfies them all. The wait bound is required: Bazel backstops a hung test with its own timeout, but plain pytest has none, so an unbounded reactive wait could hang a Reboot application's suite forever. On expiry the failure says what the last response was still getting wrong, so a timeout diagnoses like a failed assert. The test applications grow a `deposit_later` writer that schedules the deposit as a task, giving the feature scenarios a genuinely asynchronous effect to wait on: the balance is still zero when the 'eventually' begins and changes only when the task fires. The near-miss net extends: 'eventually has' with no bound, 'within' without 'eventually', 'within 10s', and 'eventually' under a saving Given or When 'has' each raise an 'Almost' naming the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A saving clause now binds a backticked name, `balance` saved as
`frank_balance`, and every recall says ${frank_balance}: in a
state's ID, a user's ID, a bearer token, a property value, or a
predicate argument. Backticks are where the grammar binds names and
${...} is where it uses them, so the two roles read differently at
a glance, and a save name no longer looks like the quoted runtime
data, state and user IDs, it sits beside. A quoted "${name}" stays
the literal string.
The near-miss net teaches the migration: saved as "$name", "name",
$name, or a bare name each raise an 'Almost' pointing at the
backticked form, and a bare $name recall raises one pointing at
${name}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A call runs as a task instead of inline by saying so on the 'gets
a' sentence, binding the task's ID the way any property saves, and
the one waiting sentence awaits any task by its ID, whether the
scenario spawned it or a response carried it:
When the `Account` for "spawned" gets a `deposit` with
`amount=15` spawned with its task id saved as `first`
Then the `deposit` task with id "${first}" of the `Account`
completes within 30 seconds
And the result has `updated_balance=15`
The saved task ID is the canonical JSON of an `rbt.v1alpha1.TaskId`
and rides through the generated `<Method>Task.retrieve`, so a
`task_id` a response carries, e.g. from a writer that scheduled a
follow-up, saves and awaits identically. Completing records the
task's response as the result, so asserting and saving reuse the
call vocabulary; the wait bound is required, like 'eventually has',
and a bare 'completes' raises an 'Almost' asking for one. A spawned
'gets a' skips the reader refusal: a reader runs as a task too. The
proto test application's `deposit_later` now returns the scheduled
task's ID to exercise the response-carried flow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
'Given I am "alice"' was the grammar's one first-person sentence: every other sentence narrates the world in the third person, with the state as its subject, and the mixed voice showed the moment a scenario switched users mid-story. 'Given the authenticated user is "alice"' narrates the caller as part of the world instead, matches its sibling 'the bearer token is "..."', and names exactly what a servicer reads: `context.auth.user_id`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
Every scenario now says who calls before its first call: 'Given the authenticated user is "alice"', 'Given the user is unauthenticated' (no token), or 'the bearer token is "..."'. A call before any of them raises naming the two spellings. Identity was already explicit when it mattered; requiring it makes it visible when it does not, the reader of any scenario sees whether authentication is in play, and adding an authorizer to an application later cannot silently change what its unannotated scenarios were testing. The feature Backgrounds say 'And the user is unauthenticated', so the declaration is one visible line per feature, and a scenario that authenticates redeclares. The near-miss net teaches the spellings: 'I am "..."' and 'the user is anonymous' each raise an 'Almost' naming the sentence to say. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
'Given the "proxy" application is up' runs the `Application` the `proxy_application` fixture returns (the quoted name, spaces as underscores, plus `_application`), the plain 'Given the application is up' keeping the `application` fixture, so the scenarios of one feature file vary the application under test: different servicer bundles, a legacy server alongside, a stubbed dependency. A missing or mistyped fixture raises naming the fixture to define. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The `reboot` wheel now includes `reboot.bdd`, with its four dependencies, `pytest`, `pytest-bdd`, `jsonpath-ng`, and `json5`, as the `pytest-bdd` optional-dependency extra rather than as dependencies: `pip install reboot` stays as it was, and `pip install reboot[pytest-bdd]` brings the Gherkin testing layer. `pip_package` grows an `extras` attribute mapping a requirements file to an extra's name. `requirements.in` stays the one complete list the Bazel lock compiles; the wheel build strips the extra's packages out of the staged dependencies (erroring if the extra lists a package the complete list does not), stages the extra's requirements beside them, points setuptools' dynamic optional-dependencies at them, and verifies the dependency tree against the union. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The `reboot` wheel now advertises a `pytest11` entry point, `reboot.bdd_plugin`, so installing `reboot[pytest-bdd]` puts the built-in steps and their fixtures in front of every test run: a test module needs no imports beyond `from reboot.bdd import scenarios` (already re-exported) and its `application` fixture. The plugin module confirms `pytest_bdd` is importable before loading the steps, because the entry point is metadata of the `reboot` distribution and pytest follows it whether or not the extra is installed. `pip_package` grows an `entry_points` attribute, each key an entry-point group and each value that group's 'name = module' entries, rendered into the generated `pyproject.toml`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The chat room's unittest becomes `chat_room.feature` driven by `reboot.bdd`: the send/read flow as scenarios, plus the `of length` and `containing` predicates the old test had no spelling for. The example depends on `reboot[pytest-bdd]`, and its `test.sh` passes the extra through the wheel override. Locking the extra against the published 1.4.1, which does not have it yet, records it but resolves no packages; the wheel override installs them, and the next release's lock will. The testing docs page extracted its example from this example's test; it keeps its unittest example as before, inlined verbatim now that the test it extracted no longer exists, until the `reboot.bdd` documentation replaces the page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The hello-constructors unittest becomes `hello.feature` driven by `reboot.bdd`: creating through the factory, then sending and reading. With the steps arriving through the pytest plugin, the test module is its `application` fixture and a `scenarios(...)` call. The monorepo's shared project depends on `reboot[pytest-bdd]`, and its `all_pytests.sh` passes the extra through the wheel override; locking against the published 1.4.1 records the extra without resolving its packages until a release ships it, the same as the chat-room example. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The bank example's unittests become `bank.feature` and
`account.feature`. Signing up saves the response's generated
`account_id`, which later steps recall both as a state's ID,
"${alice_account_id}", and as a property value,
`from_account_id=${alice_account_id}`; overdrafts assert through
'attempts a' and 'the attempt aborts with `OverdraftError`'; and
opening an account saves the response's `welcome_email_task_id` and
awaits it with 'the `welcome_email` task with id "..." of the
`Account` completes within 30 seconds'.
The mocked email sender shows what stays outside the grammar: an
autouse fixture patches `send_email`, and a one-line custom step,
'the welcome email was sent', asserts on it (twice, because
development mode re-runs methods to validate idempotence). The
`errors` and `tasks` docs pages extracted snippets from these
tests; they keep their examples as before, inlined verbatim, until
the `reboot.bdd` documentation replaces them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The bank-pydantic unittests become `bank.feature`: the transfer flow saves each customer's generated `account_id` and recalls them as state IDs and property values, asserts the aggregate views with `of length` and `containing`, refuses the overdraft through 'attempts a', and runs the task scenario as 'gets a `deposit` ... spawned with its task id saved as `deposit_task_id`', awaiting by ID, including spawning the `balance` reader as a task. The authorizer-wiring servicer subclasses, and the no-op `interest` override that keeps balances stable, stay in the test module: the `application` fixture is where a test says which servicers, and which library dependencies like `sorted_map_library()`, a scenario runs against. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The chick-potle unittests become `food.feature`, running the real servicers with their real authorizers: 'the authenticated user is "alice"' mints the token that also constructs her `User`, the way a production sign-in does, so the old tests' explicit `_authenticated` bootstrap disappears; switching to "bob" mid-scenario shows the order's authorizer refusing another user with `PermissionDenied`, for a reader through '`get_cart` on ... aborts with' and for a writer through 'attempts a'. The menu is constant, so the old truthiness and arithmetic asserts become exact: `items` of length 10 with the first item's name, category, and price, and cart totals as literal cents. The out-of-range index, an uncaught `ValueError` in the servicer, surfaces as an abort with `Unknown`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The agent-wiki unittests become three feature files, one per
librarian stand-in, since a module's scenarios share their
fixtures: `wiki_crud.feature` runs with a model that refuses to be
called, so a scenario that accidentally wakes the librarian fails
clearly; `wiki_transcript.feature` with one that always answers the
same thing; and `wiki_ingest.feature` with the scripted model that
drives get_wiki -> create_page -> update_wiki. Each module's
autouse fixture swaps `wiki_module.librarian.wrapped.model` for the
scenario's duration.
The ingest scenario's `asyncio.Event` wait becomes 'eventually has
`content` containing "[Test Page](Page:" within 30 seconds', and
the scripted model saves the created page's ID as `page_id` the
moment its `create_page` tool returns, so the scenario recalls
${page_id} to assert the wiki's markdown references the page and
that the page carries the scripted title and body: a fixture may
save values for scenarios to recall.
Saying who the authenticated user is constructs her `User`, so the
old `_authenticated` bootstrap disappears.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A custom step takes the `world` fixture, gets a context from `world.context()`, which carries the scenario's authenticated user, and calls the generated clients directly, the way any Reboot code does; the module docstring now says so, and the pydantic test suite's custom step models it instead of calling through `World.call`. `World.call` and `World.request` remain the built-in steps' machinery, and may become private. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A predicate's argument is a JSON value, the same kind of thing a
property's value is, so it now lives where every JSON value lives:
`name` containing `"rank"`, `owners` containing `"main"`, `tags` of
length `2`, and, since a length may come from a save, `tags` of
length `${count}`. One value grammar serves assignments,
equalities, and predicate arguments, through one parser: `${name}`
recalls, a quoted "${name}" stays the literal string, JSON5
otherwise. The counts in a sentence itself, 'within 30 seconds',
stay bare words. This also clears the way for object arguments,
whose delimitation already needed the backticks.
The near-miss net teaches the move: a bare argument or length
raises 'Almost: the value goes in backticks' or 'the length goes
in backticks', and a length that parses to anything but a whole
number is refused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The hello-tasks unittest becomes `hello.feature`, chaining tasks by their IDs: the send's response carries the warning task's ID, the completed warning task's response is the result, so the erase task's ID saves from it the way any response property does, and awaiting the erase task lets the scenario assert the erasure message. The delay globals the old test zeroed become a save-and-restore autouse fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The swag store's unittests become `store.feature`, twelve
scenarios, and the first real exercise of 'the bearer token is':
the admin flow mints coupon codes under the raw admin key, saves
`codes[0]` as `coupon_code`, switches back to the authenticated
user, and checks out with `coupon_code=${coupon_code}`; the
refusal twin asserts `PermissionDenied` without the key. Checkout
covers the happy path (order created, cart emptied, coupon makes
it free), the empty-cart and invalid-coupon refusals, and coupon
redemption refusing reuse.
The residue stays beside the `application` fixture, which also
carries the `initialize=` hook creating the coupon book: an env-var
fixture for the admin key, a mocked Printful catalog fetch, the
no-op fulfillment servicer subclass, and one custom step, 'every
generated code is six digits', for the for-all the grammar does
not say.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A new `WatchBehaviors` workflow, spawned beside the API and code watchers, globs every `.feature` file under the working directory (leaving out hidden directories and `node_modules`), parses each with `gherkin-official`, and records what they declare on the `Dashboard` state. A new `Behaviors` page shows them: each feature file is a card listing its scenarios, grouped under their `Rule`s, with the feature's and each rule's `Background` beside them. A scenario row expands to its steps, with the background's steps folded in, dimmed, so an open scenario reads whole. Each step is read by the built-in steps' own grammar, which moves from `reboot/bdd/steps.py` into `reboot/bdd/grammar.py` so that the dashboard can use it without importing pytest-bdd: the regular expressions the steps register with are the ones the dashboard reads a step by. So every span of a step gets its role, and the page sets each by it: a state type or method links to its anchor on the state page, a property path, value, error type, and id each get their own colour, and a saved name and its recalls share one, lighting up together on hover. A step whose clause list is longer than two puts each clause on a line of its own. A step the grammar does not define, such as one the application defines itself, keeps its backticked spans as code and links one that names a state type or a method the same way, going by the step's own text. Parsing needs `gherkin-official`, which arrives with `reboot[pytest-bdd]` and is now an explicit requirement of the extra; without it, each feature file found is recorded with an error saying to install the extra. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A saved value was said as `${name}`, while a Scenario Outline says a
column of its Examples table as `<name>`, so a writer learned two
spellings for one idea: a value that comes from somewhere else. Now
both are `<name>`. pytest-bdd substitutes a column's value before a
step runs and leaves any other `<name>` as written, so a saved value
said the same way reaches the step untouched; a quoted `"<name>"`
stays the literal string, as before.
Because a column's `<name>` is substituted first, a save under a
column's name could never be said, so `World.save` refuses one, and
every save goes through it: the built-in steps' and a fixture's
alike, which is why agent-wiki's scripted librarian calls it too.
The `world` fixture learns the columns from pytest-bdd's example.
The dashboard's behaviors page finds `<name>` in a built-in step's
values and ids and in a custom step's text with one scan, sets each
in its variable's hue, and hues an Examples table's header cells the
same way, so hovering a variable lights up its column and every
step saying it, and hovering a column does the reverse.
bank-pydantic's overdraft scenario becomes a Scenario Outline over
an Examples table, which is the first example to say a column.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
The first `Rule` among the examples: the overdraft Scenario Outline now illustrates 'Overdrafts are refused', with the rule's prose saying what an account never does. The rule comes last in the file, since every scenario after a rule belongs to it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A feature named `Bank` is the system's noun, which the dashboard already indexes as a state type; a feature is a capability, and its rules are the invariants the capability obeys. So `bank.feature` becomes `transfers.feature` (Transferring money between accounts, under the rule that a transfer moves exactly the amount from one account to the other), `deposits.feature` (Depositing into an account, under the rule that a deposit raises the balance by the amount), and `withdrawals.feature` (Withdrawing from an account, under the rule that overdrafts are refused), each file named for its capability and each scenario named for the situation it illustrates. The scenario spawning a deposit and a read was a test of Reboot's tasks in a bank's clothing, with no capability to belong to; its coverage of a spawned reader moves to `reboot.bdd`'s own suite, whose spawned-tasks scenario now also spawns the `balance` reader, and bank-pydantic keeps the deposit as a plain scenario. The dashboard's behaviors sidebar follows: it lists each feature with its rules under it, and counts rules rather than scenarios once a project writes any, since the rules are what someone scanning the page is after; each rule has an anchor of its own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A method may declare an error model that another API file defines, e.g. a bank's `transfer` declaring the account's `OverdraftError` so that the abort of the nested `withdraw` propagates as the transfer's own. The proto writer emitted every declared error as a top-level message of the declaring file, so two files in one package defined the same message and `protoc` refused the duplicate. Each error message is now nested inside the per-method `<Type><Method>Errors` message whose `oneof` refers to it: a copy per method, the way every other model a method mentions is copied, so nothing is shared between files and nothing needs importing. The wire format is unchanged, since the field numbers and the wrapper's type URL are the same. Propagating the declared error then still failed at the caller: the generated servicer re-raised the nested call's aborted as is, so the wire carried the account's per-method error message, which the bank's client does not decode, and the caller saw a bare `Aborted`. The servicer now raises the propagated error as its own aborted type, rebuilt from the pydantic model, so it travels the way the method declares it. That is also what lets each method keep its own copy of the message. A proto API is unaffected, since its errors are the bare messages either way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A transfer for more than the source account holds aborted with `Unknown`, because `transfer` did not declare the `OverdraftError` its nested `withdraw` raises. It declares it now, so the caller sees the overdraft and by how much, and the overdraft rule's scenario asserts that instead of `Unknown`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
Two scenarios in `web.feature` drive the bank's real web app in a browser and then assert on the backend with the built-in steps: opening an account and seeing it listed, and transferring between two accounts opened by the backend steps, with their saved ids picked in the app's selects. The steps in `web_steps.py` are hand-written, a trial of what built-in browser steps in `reboot.bdd` would look like: each names an element by its ARIA role and accessible name or its label, never a selector, and maps to one Playwright call. They are plain `def`s on pytest's main thread, since Playwright's sync API cannot run on the scenario's event loop thread. The app is served the way it is deployed, from its own origin: a Vite dev server on `localhost` started once per session, with the backend at its `127.0.0.1` Envoy address handed to the app by `rebootUrl`. The browser treats those as different sites, so the scenarios pass only if Envoy's CORS allow-list admits the app's origin, which the application lists in `allowed_origins`, and the session cookie is one a browser sends cross-site. The user the scenario declared becomes that session: the minted bearer token is installed as the backend host's `rbt_session` cookie, `Secure` and `SameSite=None` like the one the backend sets, and the app's credentialed `/whoami` call turns it back into the bearer. The cookie is added by `domain`: Chromium drops a `Secure` cookie added for an `http://` URL without a word. The app's labels now point at their inputs, the accounts table is named by its heading, and only a settled account row carries the `account-id` test id, since the pending placeholder row is not an account. The web tests skip when the frontend's dependencies are not installed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
Each browser scenario now leaves a video of its run and a screenshot after each of its assertion steps in a directory next to the feature file, `web.recordings/<scenario slug>/` beside `web.feature`, laid out by `reboot.bdd.recordings`: `scenario.webm` and `<line>.png` for the step on that line. Kept in the source tree, they are checked in with the scenarios they show, so a checkout shows how each scenario looked without running anything, and an earlier version's recordings sit in history next to that version's scenarios. bank-pydantic records them from a `conftest.py`: pytest-bdd's after-step hook screenshots the page after each `then` step that drives a browser, and pytest-playwright's context arguments gain a video directory per scenario, emptied first so that nothing from an earlier run outlives the scenario that made it. Under a Bazel test, whose source tree is read only, they go under `TEST_UNDECLARED_OUTPUTS_DIR` instead. An outline's examples all write the same files, so the last example's are kept. The dashboard's behaviors watcher names each scenario's video and each step's screenshot where the files exist, watching the recordings directories as it watches the feature files, and the backend serves them at `/recordings/` from beside the feature file and nothing else. The Behaviors page shows a video pill beside a recorded scenario's name and a thumbnail at the right of each recorded step, each opening the file. Steps now carry their line, which is what names a screenshot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A scenario finishes in under two seconds when nothing waits for a person, so its video was two seconds long. The browser is now launched with Playwright's `slow_mo`, a pause after each browser operation, so that a viewer can follow each click and keystroke, and each assertion step leaves what it asserted on screen for a moment before the next step changes it. Both are options with defaults, `--recording-slowmo 500` and `--recording-dwell 1000` milliseconds, and `0` for either records without that pacing; pytest-playwright's own `--slowmo` takes precedence when given. Only the browser's operations are paced: the page, its rendering and its calls to the backend run at full speed, so nothing about what the scenarios prove changes. The recordings are re-recorded at the new pace. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
A screenshot after an assertion step showed whatever was in the viewport, which was not always the element the step asserted on: the accounts table sits below the fold once the forms are on screen. Each assertion step now records the element it looked at, and the after-step hook scrolls that element to the middle of the view and outlines it before the screenshot, leaving the outline up while the result dwells so the video shows it too. A small element, such as a figure, is outlined just outside its box; a large one, such as a table, just inside, since a container that scrolls clips an outline outside it. The recordings are re-recorded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
Recordings were keyed by the scenario's name and each screenshot by its step's line, so an edit anywhere above a step orphaned every screenshot below it, a renamed scenario left its recordings behind under the old name, and a scenario with changed steps kept a video of the old sequence with nothing to say so. Each scenario's directory now holds one directory named by a digest of what the scenario runs: its name, the steps of the backgrounds it runs under, its own steps and its examples, with its keyword, description and tags left out since they change nothing a recording shows. Screenshots are named by the step's position among the scenario's own steps. The recordings are current exactly when that digest is the current scenario's, which the dashboard checks by a directory lookup: it names the video and screenshots under the current digest and marks a scenario stale whose only recordings are under another. Editing one scenario invalidates only its own recordings, and running one scenario replaces only its own. A run sweeps what the feature file no longer accounts for: the directory of a scenario it no longer declares, and the recorded scenario's earlier digest, so each scenario has one. Both the recording hook and the dashboard parse the feature file with `reboot.bdd.feature` and compute the digest with one function in `reboot.bdd.recordings`, so they always name the same directory. The example's recordings are re-recorded under the new layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
reboot.bdd, a pytest-bdd layer for testing Reboot applications with Gherkin, plus its test suites.What a test looks like
The test module does
from reboot.bdd.steps import *and defines anapplicationfixture returning theApplicationunder test. Each scenario runs against a fresh startedRebootharness, and each step's call runs on a freshExternalContext(the way each external call in production arrives with its own) unless the scenario creates one to share viaGiven a shared context.Built-in steps
Given the application is upGiven a shared contextGiven the authenticated user is "alice"— mints a valid test token for that user ID through the application's own OAuth server and puts it on every context created from then on, so servicers seecontext.auth.user_id;Given the user is unauthenticatedcalls with no token;Given the bearer token is "..."sets a raw token instead (e.g. an admin key). All work asWhentoo, so scenarios switch users mid-flight; say who calls beforeGiven a shared context. Every scenario must say who calls before its first call — a call before any of these raises naming the spellings, so a reader always sees whether authentication is in play.Given an `Account` for "alice" gets created via `open` with `initial_balance=100`(usable asWhentoo)When the `Account` for "alice" gets a `deposit` with `amount=50`(usable asGiventoo)When the `Account` for "spawned" gets a `deposit` with `amount=15` spawned with its task id saved as `first`— the call runs as a task; thenThen the `deposit` task with id "${first}" of the `Account` completes within 30 secondsawaits it (bound required) and records its response as the result. Atask_ida response carries saves and awaits identically.When the `Account` for "bob" attempts a `withdraw` with `amount=50`Then the attempt aborts with `OverdraftError` with `amount=20`Then `balance` on the `Account` for "alice" has `balance=150`Then `balance` on the `Account` for "alice" eventually has `balance=150` within 30 seconds— holds a reactive read open, asserting against each response, until the assertions hold or the bound expires; the bound is required because plain pytest has no timeout backstop, and expiry reports the last response's mismatch like a failed assert.Then `balance` on the `Account` for "ghost" aborts with `StateNotConstructed`When `get_owner` on the `Account` for "frank" has `owner.name` saved as `owner_name`(later steps recall it as${owner_name}in a state's ID, a user's ID, a bearer token, a property value, or a predicate argument; a quoted"${owner_name}"stays the literal string)Then the result has `updated_balance=150`(asGiven/When, saves instead)The property grammar
A
has/withlist holds`path=value`assignments, each in backticks, separated by commas orand. The left side is a property path: dots nest,[0]indexes a list,["key"]reaches into a map. The right side is JSON, with JSON5's leniencies (object keys need no quotes), and an object or array value calls through the method's request type; on assertions it compares as the complete message.An asserting list (a Then
has, or the clause list after an abort's error type) can also say two predicates:`path` containing `value`asserts a substring of a string, an element of a list (compared under the response type's semantics, the way=compares), or a key of a map;`path` of length `n`asserts the length of a string, list, or map. A predicate's backticked argument is a JSON value the way a property's value is — one grammar, one parser — so it can recall${name}(a length may come from a save too), while the counts in a sentence itself, likewithin 30 seconds, stay bare words. A Given/Whenhasinstead saves:`path` saved as `name`— backticks are where the grammar binds names and holds JSON values,${name}is where it uses them.A malformed or unknown property raises instead of being skipped, and a lexical near-miss of the grammar (
:for=,containsforcontaining, a missing backtick, an assert-shaped clause under the saving keyword, a predicate in a call'swith, ...) raises anAlmost: ...error naming the fix rather than pytest-bdd's "step definition is not found".Readers are only read via
`reader` on ...;gets a/attempts arefuse them with a pointing error, and the reader steps refuse writers likewise (whether a method is a reader comes from the generatedreactively()surface, which serves exactly the unary readers). Reading records the result, so "the result" always means the most recent call any step made.State types are resolved from the
Application's servicers by class name, or by full state type name (e.g.bank.v1.Account) when more than one state type goes by the class name; both proto and pydantic codegen are supported.The examples' tests are the proof
Six example applications' test suites are converted to Gherkin on this branch — chat-room, monorepo/hello-constructors, monorepo/bank (both suites), bank-pydantic, chick-potle, and agent-wiki — each with assert-for-assert parity audited against its old unittests, and each run end-to-end through its own
test.sh: fresh venv, the built wheel with thepytest-bddextra,rbt generate, mypy, pytest. Conventions that emerged: a custom step is plain Reboot code (a context fromworld.context(), which carries the scenario's authenticated user, then calls on the generated clients); test-only stand-ins (mocked email senders, scripted LLM models, authorizer-wiring servicer subclasses) live as fixtures beside theapplicationfixture; and a fixture may save values (world.saved['name'] = ...) for scenarios to recall as${name}.How it works
pytest and pytest-bdd are synchronous while everything Reboot is
async, and we deliberately don't usepytest-asyncio(pytest-bdd never awaits step functions, so it wouldn't help anyway). Instead, each scenario runs one event loop on a background thread, created before its first step and closed after its last: one loop per scenario, matching bothunittest.IsolatedAsyncioTestCase(one loop per test) and production (one application runs on one event loop underrbt dev runandrbt serve), so nothing a scenario leaks can run on into later scenarios. The loop's teardown mirrorsIsolatedAsyncioTestCase: cancel pending tasks, shut down async generators, close.The
reboot.bddgiven/when/then/stepdecorators work like pytest-bdd's except that the decorated step function may beasync def, so custom developer steps get the same treatment as the built-ins (exercised by the custom steps intests/reboot/bdd/bdd_tests.pyandtests/reboot/bdd/pydantic/bdd_tests.py).Also in this PR
Reboot.stop()now cancels (viawait_for_tasks) themonitor_event_loop()task thatstart()creates.IsolatedAsyncioTestCasehid this leak by closing its per-test loop; on a longer-lived loop every harness left a pending task behind. Ran//tests/reboot:external_context_tests_pyto check for regressions, but this touches every harness user, so please look closely.pytest==8.4.2,pytest-bdd==8.1.0,jsonpath-ng==1.8.0(property paths), andjson5==0.15.0(property values) added toreboot/requirements.in(andmypy.iniignore sections for each).Testing
bazel test //tests/reboot/bdd:bdd_tests_py //tests/reboot/bdd/pydantic:bdd_tests_py(proto and pydantic suites, plus unit tests for name collisions, unknown properties, and reader detection).Not yet done (follow-ups)
pytest-bddas areboot[bdd]extra rather than a hard dependency; only the Bazel side is wired here.testing-bdd.mdskill reference), state assertions via a test-only raw-state read (e.g.Then the state of the `Account` for "alice" has `balance=70`), and failure/recovery steps.🤖 Generated with Claude Code
https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m