Skip to content

Add reboot.bdd: write Reboot tests in Gherkin with pytest-bdd - #149

Draft
benh wants to merge 37 commits into
mainfrom
pytest-bdd
Draft

Add reboot.bdd: write Reboot tests in Gherkin with pytest-bdd#149
benh wants to merge 37 commits into
mainfrom
pytest-bdd

Conversation

@benh

@benh benh commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Adds reboot.bdd, a pytest-bdd layer for testing Reboot applications with Gherkin, plus its test suites.

What a test looks like

Feature: Accounts

  Background:
    Given the application is up
    And the user is unauthenticated

  Scenario: Depositing adds to the balance
    Given an `Account` for "alice" gets created via `open` with `initial_balance=100`
    When the `Account` for "alice" gets a `deposit` with `amount=50`
    Then the result has `updated_balance=150`
    And `balance` on the `Account` for "alice" has `balance=150`

The test module does from reboot.bdd.steps import * and defines an application fixture returning the Application under test. Each scenario runs against a fresh started Reboot harness, and 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.

Built-in steps

  • Given the application is up
  • Given a shared context
  • Given 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 see context.auth.user_id; Given the user is unauthenticated calls with no token; Given the bearer token is "..." sets a raw token instead (e.g. an admin key). All work as When too, so scenarios switch users mid-flight; say who calls before Given 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 as When too)
  • When the `Account` for "alice" gets a `deposit` with `amount=50` (usable as Given too)
  • 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; then Then the `deposit` task with id "${first}" of the `Account` completes within 30 seconds awaits it (bound required) and records its response as the result. A task_id a 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` (as Given/When, saves instead)

The property grammar

A has/with list holds `path=value` assignments, each in backticks, separated by commas or and. 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, like within 30 seconds, stay bare words. A Given/When has instead 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 =, contains for containing, a missing backtick, an assert-shaped clause under the saving keyword, a predicate in a call's with, ...) raises an Almost: ... error naming the fix rather than pytest-bdd's "step definition is not found".

Readers are only read via `reader` on ...; gets a/attempts a refuse them with a pointing error, and the reader steps refuse writers likewise (whether a method is a reader comes from the generated reactively() 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 the pytest-bdd extra, rbt generate, mypy, pytest. Conventions that emerged: a custom step is plain Reboot code (a context from world.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 the application fixture; 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 use pytest-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 both unittest.IsolatedAsyncioTestCase (one loop per test) and production (one application runs on one event loop under rbt dev run and rbt serve), so nothing a scenario leaks can run on into later scenarios. The loop's teardown mirrors IsolatedAsyncioTestCase: cancel pending tasks, shut down async generators, close.

The reboot.bdd given/when/then/step decorators work like pytest-bdd's except that the decorated step function may be async def, so custom developer steps get the same treatment as the built-ins (exercised by the custom steps in tests/reboot/bdd/bdd_tests.py and tests/reboot/bdd/pydantic/bdd_tests.py).

Also in this PR

  • Reboot.stop() now cancels (via wait_for_tasks) the monitor_event_loop() task that start() creates. IsolatedAsyncioTestCase hid 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_py to 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), and json5==0.15.0 (property values) added to reboot/requirements.in (and mypy.ini ignore 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)

  • The published wheel should probably get pytest-bdd as a reboot[bdd] extra rather than a hard dependency; only the Bazel side is wired here.
  • Docs (a testing-bdd.md skill 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

@aviator-app

aviator-app Bot commented Sep 1, 2026

Copy link
Copy Markdown

Current Aviator status

Aviator will automatically update this comment as the status of the PR changes.
Comment /aviator refresh to force Aviator to re-examine your PR (or learn about other /aviator commands).

This pull request is currently open (not queued).

How to merge

To merge this PR, comment /aviator merge or add the mergequeue-ready label.


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
benh force-pushed the pytest-bdd branch 25 times, most recently from 9aa02e5 to 28ca315 Compare September 2, 2026 03:29
benh and others added 2 commits September 2, 2026 03:34
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
benh and others added 30 commits September 3, 2026 19:48
'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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant