From 810df84a678a78cebeb1788f4879b597f434478b Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Tue, 1 Sep 2026 18:26:37 +0000 Subject: [PATCH 01/42] Cancel the event loop monitoring task in `Reboot.stop()` `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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/aio/BUILD.bazel | 1 + reboot/aio/reboot.py | 22 +++++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/reboot/aio/BUILD.bazel b/reboot/aio/BUILD.bazel index 754938a7b..18b0ec065 100644 --- a/reboot/aio/BUILD.bazel +++ b/reboot/aio/BUILD.bazel @@ -344,6 +344,7 @@ py_library( ":tracing_py", "//reboot:naming_py", "//reboot:run_environments_py", + "//reboot:wait_for_tasks_py", "//reboot/aio:servers_py", "//reboot/aio/auth:authorizers_py", "//reboot/aio/auth:token_verifiers_py", diff --git a/reboot/aio/reboot.py b/reboot/aio/reboot.py index 61c10eeef..d5fa3ed73 100644 --- a/reboot/aio/reboot.py +++ b/reboot/aio/reboot.py @@ -40,6 +40,7 @@ ENVVAR_LOCAL_ENVOY_USE_TLS, ENVVAR_REBOOT_CLOUD_DATABASE_ADDRESS, ) +from reboot.wait_for_tasks import wait_for_tasks from typing import Awaitable, Callable, Optional, Sequence, overload # The default number of servers run by a Reboot instance (including in @@ -91,6 +92,7 @@ def __init__( self._local_envoy_tls: Optional[bool] = None self._local_envoy_picked_port: Optional[int] = None self._channel_manager: Optional[_ChannelManager] = None + self._monitor_event_loop_task: Optional[asyncio.Task] = None self._application_name = application_name or DEFAULT_APPLICATION_NAME application_id = os.environ.get(ENVVAR_REBOOT_APPLICATION_ID) @@ -703,11 +705,21 @@ async def stop(self) -> None: try: await self._placement_planner.stop() finally: - # Stop the local Envoy if one was started. This is - # critical for test isolation - the Envoy has a - # grpc.aio server that must be stopped before the - # event loop closes. - await self._server_manager.stop_local_envoy() + try: + # Stop the local Envoy if one was started. + # This is critical for test isolation - the + # Envoy has a grpc.aio server that must be + # stopped before the event loop closes. + await self._server_manager.stop_local_envoy() + finally: + # The event loop monitoring started in + # `start()` runs until cancelled; without this + # its task would outlive this instance on a + # long-lived event loop. + await wait_for_tasks( + [self._monitor_event_loop_task], + cancel=True, + ) if self._database_server is not None: # Shutdown the sidecar server. We only do this during From c694926ebd6301c4396142758f54d96831c75d98 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Tue, 1 Sep 2026 18:29:37 +0000 Subject: [PATCH 02/42] Add `reboot.bdd`: pytest-bdd support for testing Reboot applications 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- mypy.ini | 4 + reboot/bdd/BUILD.bazel | 64 +++++ reboot/bdd/__init__.py | 124 ++++++++++ reboot/bdd/fixtures.py | 150 ++++++++++++ reboot/bdd/loop.py | 101 ++++++++ reboot/bdd/registry.py | 63 +++++ reboot/bdd/steps.py | 271 +++++++++++++++++++++ reboot/requirements.in | 2 + reboot/requirements_lock.txt | 98 +++++++- tests/reboot/BUILD.bazel | 1 + tests/reboot/bdd/BUILD.bazel | 58 +++++ tests/reboot/bdd/account.proto | 71 ++++++ tests/reboot/bdd/account_servicer.py | 58 +++++ tests/reboot/bdd/accounts.feature | 28 +++ tests/reboot/bdd/bdd_tests.py | 32 +++ tests/reboot/bdd/collision_tests.py | 41 ++++ tests/reboot/bdd/collisions.feature | 10 + tests/reboot/bdd/conftest.py | 10 + tests/reboot/bdd/other/BUILD.bazel | 30 +++ tests/reboot/bdd/other/account.proto | 38 +++ tests/reboot/bdd/other/account_servicer.py | 33 +++ tests/reboot/pytest_main.py | 14 ++ 22 files changed, 1300 insertions(+), 1 deletion(-) create mode 100644 reboot/bdd/BUILD.bazel create mode 100644 reboot/bdd/__init__.py create mode 100644 reboot/bdd/fixtures.py create mode 100644 reboot/bdd/loop.py create mode 100644 reboot/bdd/registry.py create mode 100644 reboot/bdd/steps.py create mode 100644 tests/reboot/bdd/BUILD.bazel create mode 100644 tests/reboot/bdd/account.proto create mode 100644 tests/reboot/bdd/account_servicer.py create mode 100644 tests/reboot/bdd/accounts.feature create mode 100644 tests/reboot/bdd/bdd_tests.py create mode 100644 tests/reboot/bdd/collision_tests.py create mode 100644 tests/reboot/bdd/collisions.feature create mode 100644 tests/reboot/bdd/conftest.py create mode 100644 tests/reboot/bdd/other/BUILD.bazel create mode 100644 tests/reboot/bdd/other/account.proto create mode 100644 tests/reboot/bdd/other/account_servicer.py create mode 100644 tests/reboot/pytest_main.py diff --git a/mypy.ini b/mypy.ini index e7aac1df1..e3ba671b3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -124,6 +124,10 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pyprctl.*] ignore_missing_imports = True +[mypy-pytest] +ignore_missing_imports = True +[mypy-pytest_bdd.*] +ignore_missing_imports = True [mypy-requests.*] ignore_missing_imports = True [mypy-six.*] diff --git a/reboot/bdd/BUILD.bazel b/reboot/bdd/BUILD.bazel new file mode 100644 index 000000000..b4b8cd321 --- /dev/null +++ b/reboot/bdd/BUILD.bazel @@ -0,0 +1,64 @@ +load("@rbt_pypi//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_library") + +py_library( + name = "__init___py", + srcs = ["__init__.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + ":loop_py", + requirement("pytest-bdd"), + ], +) + +py_library( + name = "loop_py", + srcs = ["loop.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], +) + +py_library( + name = "registry_py", + srcs = ["registry.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + "//reboot/aio:applications_py", + "//reboot/aio:servicers_py", + ], +) + +py_library( + name = "fixtures_py", + srcs = ["fixtures.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + ":__init___py", + requirement("pytest"), + "//reboot/aio:aborted_py", + "//reboot/aio:applications_py", + "//reboot/aio:external_py", + "//reboot/aio:tests_py", + ], +) + +py_library( + name = "steps_py", + srcs = ["steps.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + ":__init___py", + ":fixtures_py", + ":registry_py", + requirement("pytest"), + requirement("pytest-bdd"), + "//reboot/aio:aborted_py", + "//reboot/aio:applications_py", + "//reboot/aio:external_py", + "//reboot/aio:tests_py", + ], +) diff --git a/reboot/bdd/__init__.py b/reboot/bdd/__init__.py new file mode 100644 index 000000000..4d24d4b52 --- /dev/null +++ b/reboot/bdd/__init__.py @@ -0,0 +1,124 @@ +"""pytest-bdd support for testing Reboot applications with Gherkin. + +A test module gets the built-in steps and their fixtures with: + + from reboot.bdd.steps import * + +and defines its own steps with the `given`, `when`, `then`, and `step` +decorators below, which work exactly like pytest-bdd's except that the +decorated step function may be `async def`: such a step runs on the +same event loop as the Reboot test harness and every built-in step. +""" + +import functools +import inspect +import pytest_bdd +# Re-exported so that a test module can get everything it needs from +# `reboot.bdd`. +from pytest_bdd import parsers as parsers +from pytest_bdd import scenario as scenario +from pytest_bdd import scenarios as scenarios +from reboot.bdd.loop import run +from typing import Any, Callable, Optional + +StepDecorator = Callable[[Callable[..., Any]], Callable[..., Any]] + + +# Why the decorators below wrap step functions at all: pytest-bdd +# executes every step function synchronously, calling it and using +# whatever it returns, so the coroutine an `async def` step function +# returns would be discarded without ever being awaited and the step +# would silently do nothing. Wrapping supplies the missing await: +# calling the wrapper runs the coroutine to completion on the +# scenario's event loop, the same loop the `Reboot` harness and the +# built-in steps run on. +def _synchronous(step_function: Callable[..., Any]) -> Callable[..., Any]: + """Returns the given step function as a synchronous function that + pytest-bdd can call: an `async def` step function gets wrapped to + run on the scenario's event loop, any other function is returned + unchanged. + """ + if not inspect.iscoroutinefunction(step_function): + return step_function + + @functools.wraps(step_function) + def synchronous(*args: Any, **kwargs: Any) -> Any: + return run(step_function(*args, **kwargs)) + + return synchronous + + +def _step_decorator( + pytest_bdd_decorator: Callable[..., StepDecorator], + name: Any, + converters: Optional[dict[str, Callable[[str], Any]]], + target_fixture: Optional[str], + stacklevel: int, +) -> StepDecorator: + """Returns a step decorator that synchronizes the step function and + then applies the given pytest-bdd decorator, bumping `stacklevel` + past this wrapper so that the step definition registers in the + module that applied the decorator. + """ + + def decorator(step_function: Callable[..., Any]) -> Callable[..., Any]: + return pytest_bdd_decorator( + name, + converters=converters, + target_fixture=target_fixture, + stacklevel=stacklevel + 1, + )(_synchronous(step_function)) + + return decorator + + +def given( + name: Any, + converters: Optional[dict[str, Callable[[str], Any]]] = None, + target_fixture: Optional[str] = None, + stacklevel: int = 1, +) -> StepDecorator: + """Like `pytest_bdd.given`, except that the decorated step function + may be `async def`.""" + return _step_decorator( + pytest_bdd.given, name, converters, target_fixture, stacklevel + ) + + +def when( + name: Any, + converters: Optional[dict[str, Callable[[str], Any]]] = None, + target_fixture: Optional[str] = None, + stacklevel: int = 1, +) -> StepDecorator: + """Like `pytest_bdd.when`, except that the decorated step function + may be `async def`.""" + return _step_decorator( + pytest_bdd.when, name, converters, target_fixture, stacklevel + ) + + +def then( + name: Any, + converters: Optional[dict[str, Callable[[str], Any]]] = None, + target_fixture: Optional[str] = None, + stacklevel: int = 1, +) -> StepDecorator: + """Like `pytest_bdd.then`, except that the decorated step function + may be `async def`.""" + return _step_decorator( + pytest_bdd.then, name, converters, target_fixture, stacklevel + ) + + +def step( + name: Any, + converters: Optional[dict[str, Callable[[str], Any]]] = None, + target_fixture: Optional[str] = None, + stacklevel: int = 1, +) -> StepDecorator: + """Like `pytest_bdd.step`, except that the decorated step function + may be `async def`.""" + return _step_decorator( + pytest_bdd.step, name, converters, target_fixture, stacklevel + ) diff --git a/reboot/bdd/fixtures.py b/reboot/bdd/fixtures.py new file mode 100644 index 000000000..cf2e64e53 --- /dev/null +++ b/reboot/bdd/fixtures.py @@ -0,0 +1,150 @@ +"""The pytest fixtures that the built-in `reboot.bdd` steps run on.""" + +import pytest +from dataclasses import dataclass, field +from reboot.aio.aborted import Aborted +from reboot.aio.external import ExternalContext +from reboot.aio.tests import Reboot +from reboot.bdd.loop import EventLoopThread, start_event_loop, stop_event_loop +from typing import Any, Callable, Iterator, Optional + + +@pytest.fixture(autouse=True) +def reboot_event_loop() -> Iterator[EventLoopThread]: + """The scenario's event loop, which `reboot.bdd.run()` and every + `async def` step run on. One loop per scenario, the way one + application runs on one event loop under `rbt dev run` and + `rbt serve`.""" + event_loop = start_event_loop() + try: + yield event_loop + finally: + stop_event_loop(event_loop) + + +@pytest.fixture +def rbt(reboot_event_loop: EventLoopThread) -> Iterator[Reboot]: + """A fresh `Reboot` test harness on the scenario's event loop, + started before the scenario and stopped after it.""" + reboot = Reboot() + reboot_event_loop.run(reboot.start()) + try: + yield reboot + finally: + reboot_event_loop.run(reboot.stop()) + + +@dataclass +class World: + """What one scenario's steps have accumulated so far. + + Mutable: steps write what they did so that later steps can assert + on it. + """ + + # The generated client class of each of the application's state + # types, under the full state type name (e.g. 'bank.v1.Account'); + # empty until the application is up. + client_types: dict[str, type] = field(default_factory=dict) + + # The harness the scenario's application runs on; `None` until + # the application is up. + rbt: Optional[Reboot] = None + + # The scenario's name; every context's name begins with it. + name: Optional[str] = None + + # The context every call shares, once a 'Given a shared context' + # step has created it; while `None`, each call runs on a fresh + # context, the way each external call in production arrives with + # its own. + shared_context: Optional[ExternalContext] = None + + # Number of contexts created so far; makes each context's name + # unique within the scenario. + contexts_created: int = 0 + + # The response of the most recent call a step made. + response: Optional[Any] = None + + # The error the most recent 'attempts' step's call aborted with, + # or `None` if that call succeeded. + aborted: Optional[Aborted] = None + + def context(self) -> ExternalContext: + """The context for one step's call: the scenario's shared + context once a 'Given a shared context' step has created it, + otherwise a fresh context.""" + if self.shared_context is not None: + return self.shared_context + if self.rbt is None: + raise ValueError( + "The application is not up; start the scenario with " + "'Given the application is up'" + ) + self.contexts_created += 1 + return self.rbt.create_external_context( + name=f"{self.name}-{self.contexts_created}" + ) + + def client_type(self, state_type: str) -> Any: + """The generated client class of the named state type, named + by its full state type name (e.g. 'bank.v1.Account') or, when + only one state type goes by it, its unqualified name (e.g. + 'Account'); raises if the application serves no such state + type or the unqualified name is ambiguous.""" + client_type = self.client_types.get(state_type) + if client_type is not None: + return client_type + qualified = sorted( + name for name in self.client_types + if name.endswith('.' + state_type) + ) + if len(qualified) == 1: + return self.client_types[qualified[0]] + if len(qualified) > 1: + raise ValueError( + f"`{state_type}` names more than one of the " + "application's state types; say one of: " + + ', '.join(f'`{name}`' for name in qualified) + ) + raise ValueError( + f"Unknown state type `{state_type}`; the application " + "serves: " + + (', '.join(sorted(self.client_types)) or "no state types") + ) + + def factory( + self, + *, + state_type: str, + method: str, + ) -> Callable[..., Any]: + """The named factory on the state type's generated client + class; raises if there is none.""" + factory = getattr(self.client_type(state_type), method, None) + if not callable(factory): + raise ValueError(f"`{state_type}` has no factory `{method}`") + return factory + + async def call( + self, + *, + state_type: str, + state_id: str, + method: str, + properties: dict[str, Any], + ) -> Any: + """Calls the named method on the named state, with the + properties as the request's, and returns its response.""" + reference = self.client_type(state_type).ref(state_id) + method_callable = getattr(reference, method, None) + if not callable(method_callable): + raise ValueError(f"`{state_type}` has no method `{method}`") + return await method_callable(self.context(), **properties) + + +@pytest.fixture +def world() -> World: + """The scenario's world: the mutable record its steps share.""" + return World() diff --git a/reboot/bdd/loop.py b/reboot/bdd/loop.py new file mode 100644 index 000000000..5d9cc3cb6 --- /dev/null +++ b/reboot/bdd/loop.py @@ -0,0 +1,101 @@ +"""The per-scenario event loop that `reboot.bdd`'s Reboot calls run +on. + +pytest and pytest-bdd call step functions and fixtures synchronously, +while everything Reboot is `async`. Each scenario runs one event loop +on a background thread, created before its first step and closed +after its last; `run()` submits a coroutine to the current scenario's +loop and blocks until the coroutine completes. Everything in a +scenario that must share a loop (the `Reboot` harness, its contexts, +the calls the steps make) runs on that one loop. + +One loop per scenario matches both `unittest.IsolatedAsyncioTestCase` +(one loop per test) and production (one application runs on one event +loop for its lifetime under `rbt dev run` and `rbt serve`), and it +keeps anything a scenario leaks from running on into later scenarios. +""" + +import asyncio +import threading +from typing import Any, Coroutine, Optional, TypeVar + +T = TypeVar('T') + + +class EventLoopThread: + """An event loop running on its own daemon thread from + construction until `stop()`. + + The loop's lifecycle belongs to `asyncio.run()`, so stopping gets + the standard library's shutdown: any still pending tasks are + cancelled and waited for (with unretrieved exceptions reported + through the loop's exception handler), async generators and the + default executor are shut down, and the loop is closed. + """ + + def __init__(self) -> None: + self._started = threading.Event() + self._thread = threading.Thread( + target=lambda: asyncio.run(self._run_until_stopped()), + name="reboot-bdd-event-loop", + daemon=True, + ) + self._thread.start() + self._started.wait() + + async def _run_until_stopped(self) -> None: + """Publishes the running event loop and blocks until `stop()`, + keeping the loop serving `run()` submissions in between.""" + self._loop = asyncio.get_running_loop() + self._stopped = asyncio.Event() + self._started.set() + await self._stopped.wait() + + def run(self, coroutine: Coroutine[Any, Any, T]) -> T: + """Runs the coroutine on this event loop, blocking the calling + thread until the coroutine completes, and returning its result + or raising its exception.""" + return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result() + + def stop(self) -> None: + """Stops and closes the event loop, cancelling any still + pending tasks the way `unittest.IsolatedAsyncioTestCase` ends + a test, and joins the loop's thread.""" + self._loop.call_soon_threadsafe(self._stopped.set) + self._thread.join() + + +# The current scenario's event loop, which `run()` submits to; one is +# current from `start_event_loop()` until `stop_event_loop()`. +_current_event_loop: Optional[EventLoopThread] = None + + +def start_event_loop() -> EventLoopThread: + """Starts an event loop and makes it the one `run()` submits to. + One at a time: the previously started one must have been + stopped.""" + global _current_event_loop + assert _current_event_loop is None + _current_event_loop = EventLoopThread() + return _current_event_loop + + +def stop_event_loop(event_loop: EventLoopThread) -> None: + """Stops the given event loop, which must be the one `run()` + submits to, leaving `run()` without an event loop.""" + global _current_event_loop + assert _current_event_loop is event_loop + _current_event_loop = None + event_loop.stop() + + +def run(coroutine: Coroutine[Any, Any, T]) -> T: + """Runs the coroutine on the current scenario's event loop, + blocking the calling thread until the coroutine completes, and + returning its result or raising its exception.""" + if _current_event_loop is None: + raise ValueError( + "`run()` submits to the current scenario's event loop, so " + "it can only be called while a scenario is running" + ) + return _current_event_loop.run(coroutine) diff --git a/reboot/bdd/registry.py b/reboot/bdd/registry.py new file mode 100644 index 000000000..90669ae53 --- /dev/null +++ b/reboot/bdd/registry.py @@ -0,0 +1,63 @@ +"""Resolves the state type names that step text mentions, e.g. the +`Account` in 'the `Account` for "alice"', to the generated client +classes those calls go through.""" + +import sys +from reboot.aio.applications import Application +from reboot.aio.servicers import Servicer +from typing import Optional + + +def _client_type(servicer_type: type[Servicer]) -> type: + """Returns the generated client class (the class with `ref()`) for + the state type the given servicer serves. + + The generated `*_rbt.py` module defines both the servicer base + class the developer subclassed and, under the last segment of the + state type name, the client class. Walk the servicer's bases to + the generated one and look the client class up in its module. + """ + for base in servicer_type.__mro__: + state_type_name = base.__dict__.get('__state_type_name__') + if state_type_name is None: + continue + class_name = str(state_type_name).split('.')[-1] + client_type: Optional[type] = getattr( + sys.modules[base.__module__], class_name, None + ) + if client_type is None: + continue + client_state_type_name = getattr( + client_type, '__state_type_name__', None + ) + if ( + client_state_type_name == state_type_name and + hasattr(client_type, 'ref') + ): + return client_type + raise ValueError( + f"Could not resolve the generated client class for servicer " + f"'{servicer_type.__name__}'; expected one of its base classes " + "to come from a generated `*_rbt.py` module" + ) + + +def client_types_by_name(application: Application) -> dict[str, type]: + """Returns the generated client class of each of the application's + servicers, keyed by the full state type name (e.g. + 'bank.v1.Account').""" + client_types: dict[str, type] = {} + for servicer_type in application._servicers or []: + client_type = _client_type(servicer_type) + state_type_name = str(getattr(client_type, '__state_type_name__')) + already_registered = client_types.get(state_type_name) + if ( + already_registered is not None and + already_registered is not client_type + ): + raise ValueError( + f"State type '{state_type_name}' resolves to two " + "different generated client classes" + ) + client_types[state_type_name] = client_type + return client_types diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py new file mode 100644 index 000000000..d6d6d3016 --- /dev/null +++ b/reboot/bdd/steps.py @@ -0,0 +1,271 @@ +"""The built-in `reboot.bdd` steps. + +A test module makes these steps, and the fixtures they run on, +available to its scenarios with: + + from reboot.bdd.steps import * + +The steps run against the `Application` returned by the +`application` fixture, which each test suite defines in its +`conftest.py` or test module, for example: + + @pytest.fixture + def application() -> Application: + return Application(servicers=[AccountServicer]) + +Step text refers to a state type by its class name in backticks (or +by its full state type name, e.g. `bank.v1.Account`, when more than +one state type goes by the class name), to a state's ID in double +quotes, and to properties as a list of +`name=value` pairs, each in backticks, separated by commas or 'and', +whose values are Python literals: + + Given the application is up + And an `Account` for "alice" gets created via `open` + When the `Account` for "alice" gets a `deposit` with `amount=50` + Then `balance` on the `Account` for "alice" has + `balance=50` +""" + +# The step functions below take the `rbt` and `world` +# fixtures as parameters, which 'ruff' sees as shadowing this module's +# re-exports of those fixtures, so we need to silence their error. +# +# ruff: noqa: F811 + +import ast +import pytest +import re +from pytest_bdd import parsers +from reboot.aio.aborted import Aborted +from reboot.aio.applications import Application +from reboot.aio.tests import Reboot +from reboot.bdd import given, then, when +# Re-exported so that `from reboot.bdd.steps import *` brings in the +# fixtures the steps run on. +from reboot.bdd.fixtures import World +from reboot.bdd.fixtures import rbt as rbt +from reboot.bdd.fixtures import reboot_event_loop as reboot_event_loop +from reboot.bdd.fixtures import world as world +from reboot.bdd.registry import client_types_by_name +from typing import Any, Optional + +# One 'name=value' property in step text: the name (possibly dotted, +# to reach a nested property) and value in backticks, the value +# being anything up to the closing backtick. +_PROPERTY_PATTERN = re.compile(r'`(?P\w+(?:\.\w+)*)=(?P[^`]+)`') + +# What separates two properties in step text: a comma, an 'and', or a +# comma followed by an 'and'. +_SEPARATOR_PATTERN = re.compile(r'\s*(?:,\s*and|,|and)\s+') + +# The 'the `Account` for "alice"' phrase naming the state a step acts +# on. +_STATE = r'the `(?P[\w.]+)` for "(?P[^"]*)"' + +# A step's optional trailing property list. +_PROPERTIES = r'(?: with (?P.+))?' + + +def _parse_properties(properties: Optional[str]) -> dict[str, Any]: + """Parses a step's property list, e.g. '`amount=50` and + `reason="promo"`', into a dictionary of Python literal values.""" + if properties is None: + return {} + parsed: dict[str, Any] = {} + text = properties.strip() + position = 0 + while position < len(text): + if position > 0: + separator = _SEPARATOR_PATTERN.match(text, position) + if separator is None: + raise ValueError( + "Expected a ',' or 'and' between properties, but " + f"got: {text[position:]}" + ) + position = separator.end() + property_match = _PROPERTY_PATTERN.match(text, position) + if property_match is None: + raise ValueError( + "Expected a property of the form `name=value`, but " + f"got: {text[position:]}" + ) + try: + value = ast.literal_eval(property_match['value']) + except (ValueError, SyntaxError) as error: + raise ValueError( + f"The value of `{property_match['name']}` must be a Python " + "literal, e.g. 50, 2.5, \"text\", or True, but got: " + f"{property_match['value']}" + ) from error + parsed[property_match['name']] = value + position = property_match.end() + return parsed + + +def _assert_properties(subject: Any, properties: dict[str, Any]) -> None: + """Asserts that each of the given (possibly dotted) property names + reaches the expected value on the given response, state, or + error.""" + for name, expected in properties.items(): + actual = subject + for attribute in name.split('.'): + try: + actual = getattr(actual, attribute) + except AttributeError as error: + raise AssertionError( + f"Expected `{type(actual).__name__}` to have a " + f"property `{attribute}` (from " + f"`{name}={expected!r}`), but it has no such " + "property" + ) from error + assert actual == expected, ( + f"Expected `{name}` to be {expected!r}, but it is {actual!r}" + ) + + +@given('the application is up') +async def _the_application_is_up( + rbt: Reboot, + application: Application, + world: World, + request: pytest.FixtureRequest, +) -> None: + await rbt.up(application) + world.client_types = client_types_by_name(application) + world.rbt = rbt + world.name = request.node.name + + +@given('a shared context') +def _a_shared_context(world: World) -> None: + world.shared_context = world.context() + + +@given( + parsers.re( + r'(?:a|an) `(?P[\w.]+)` for "(?P[^"]*)" ' + rf'gets created via `(?P\w+)`{_PROPERTIES}$' + ) +) +async def _gets_created_via( + world: World, + state_type: str, + state_id: str, + method: str, + properties: Optional[str], +) -> None: + factory = world.factory(state_type=state_type, method=method) + try: + _, world.response = await factory( + world.context(), state_id, **_parse_properties(properties) + ) + except Aborted as aborted: + raise AssertionError( + f"Creating the `{state_type}` for \"{state_id}\" via " + f"`{method}` {aborted}" + ) from aborted + + +@given(parsers.re(rf'{_STATE} gets a `(?P\w+)`{_PROPERTIES}$')) +@when(parsers.re(rf'{_STATE} gets a `(?P\w+)`{_PROPERTIES}$')) +async def _gets_a( + world: World, + state_type: str, + state_id: str, + method: str, + properties: Optional[str], +) -> None: + try: + world.response = await world.call( + state_type=state_type, + state_id=state_id, + method=method, + properties=_parse_properties(properties), + ) + except Aborted as aborted: + raise AssertionError( + f"The `{state_type}` for \"{state_id}\" getting a " + f"`{method}` {aborted}; to assert an expected abort, " + "write 'attempts a' with 'Then the attempt aborts with " + f"`{type(aborted.error).__name__}`'" + ) from aborted + + +@when(parsers.re(rf'{_STATE} attempts a `(?P\w+)`{_PROPERTIES}$')) +async def _attempts_a( + world: World, + state_type: str, + state_id: str, + method: str, + properties: Optional[str], +) -> None: + try: + world.response = await world.call( + state_type=state_type, + state_id=state_id, + method=method, + properties=_parse_properties(properties), + ) + world.aborted = None + except Aborted as aborted: + world.aborted = aborted + + +@then( + parsers.re( + r'the attempt aborts with `(?P\w+)`' + r'(?: where (?P.+))?$' + ) +) +def _the_attempt_aborts_with( + world: World, + error_type: str, + properties: Optional[str], +) -> None: + assert world.aborted is not None, ( + "Expected the most recent 'attempts' step to have aborted, " + "but it succeeded" + ) + error = world.aborted.error + assert type(error).__name__ == error_type, ( + f"Expected the attempt to have aborted with `{error_type}`, " + f"but it aborted with `{type(error).__name__}`: " + f"{world.aborted}" + ) + _assert_properties(error, _parse_properties(properties)) + + +@then( + parsers.re(rf'`(?P\w+)` on {_STATE} ' + r'has (?P.+)$') +) +async def _has( + world: World, + method: str, + state_type: str, + state_id: str, + properties: str, +) -> None: + try: + response = await world.call( + state_type=state_type, + state_id=state_id, + method=method, + properties={}, + ) + except Aborted as aborted: + raise AssertionError( + f"`{method}` on the `{state_type}` for \"{state_id}\" " + f"{aborted}" + ) from aborted + _assert_properties(response, _parse_properties(properties)) + + +@then(parsers.re(r'the response has (?P.+)$')) +def _the_response_has(world: World, properties: str) -> None: + assert world.response is not None, ( + "Expected a preceding step to have made a call that returned " + "a response, but there is none" + ) + _assert_properties(world.response, _parse_properties(properties)) diff --git a/reboot/requirements.in b/reboot/requirements.in index 5eeef6bd1..efae38261 100644 --- a/reboot/requirements.in +++ b/reboot/requirements.in @@ -23,6 +23,8 @@ pathspec==0.12.1 # Latest as of 2024/04/22. protobuf==5.28.3 # Aligned with `grpcio`. psutil==6.0.0 # Latest as of 2024/09/10. pyjwt==2.10.1 # Latest as of 2024/11/27. +pytest==8.4.2 # For `reboot.bdd`; latest 8.x as of 2026/09/01. +pytest-bdd==8.1.0 # For `reboot.bdd`; latest as of 2026/09/01. python-dotenv==1.2.1 # Used by `rbt dev run --env-file`. python-ulid==3.1.0 # Latest as of 2026/03/12. pyprctl==0.1.3 # Latest as of 2023/06/04. diff --git a/reboot/requirements_lock.txt b/reboot/requirements_lock.txt index b08451900..0c12cde15 100644 --- a/reboot/requirements_lock.txt +++ b/reboot/requirements_lock.txt @@ -400,6 +400,7 @@ exceptiongroup==1.2.2 \ # via # anyio # pydantic-ai-slim + # pytest fastapi==0.115.12 \ --hash=sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681 \ --hash=sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d @@ -473,6 +474,10 @@ genai-prices==0.0.57 \ --hash=sha256:14e50fb69cdc5a06ddb2a6df5a7fe06741b9e44304ce3f1728f56abdf1856cca \ --hash=sha256:6e101e9c53975557ceffa237b0995787d81fe75aac12410f2898504188bcad89 # via pydantic-ai-slim +gherkin-official==29.0.0 \ + --hash=sha256:26967b0d537a302119066742669e0e8b663e632769330be675457ae993e1d1bc \ + --hash=sha256:dbea32561158f02280d7579d179b019160d072ce083197625e2f80a6776bb9eb + # via pytest-bdd googleapis-common-protos==1.65.0 \ --hash=sha256:2972e6c496f435b92590fd54045060867f3fe9be2c82ab148fc8885035479a63 \ --hash=sha256:334a29d07cddc3aa01dee4988f9afd9b2916ee2ff49d6b757155dc0d197852c0 @@ -648,6 +653,10 @@ importlib-metadata==8.5.0 \ --hash=sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b \ --hash=sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7 # via opentelemetry-api +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest jinja2==3.1.2 \ --hash=sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852 \ --hash=sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61 @@ -674,6 +683,10 @@ logfire-api==4.32.1 \ --hash=sha256:4b4c27cf6e27e8e26ef4b22a77f2a2988dd1d07e2d24ee70673ef34b234fb8a5 \ --hash=sha256:5e8714b2bb5fb5d1f4a4a833941e4ca711b75d2c1f98e76c5ad680fe6991af6a # via pydantic-graph +mako==1.4.1 \ + --hash=sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617 \ + --hash=sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27 + # via pytest-bdd markupsafe==2.1.3 \ --hash=sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e \ --hash=sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e \ @@ -735,7 +748,9 @@ markupsafe==2.1.3 \ --hash=sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc \ --hash=sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2 \ --hash=sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11 - # via jinja2 + # via + # jinja2 + # mako mcp==1.27.0 \ --hash=sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741 \ --hash=sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83 @@ -891,10 +906,26 @@ packaging==23.1 \ # via # -r reboot/requirements.in # opentelemetry-instrumentation + # pytest + # pytest-bdd +parse==1.22.1 \ + --hash=sha256:20f0925a46f06602485ac90d751764d0697fd8455aaa97489ba8953a4b66de32 \ + --hash=sha256:d3a4740ec3da338e2b258b2d69741b731eadfddca59e24a14bc4ee5fce38c911 + # via + # parse-type + # pytest-bdd +parse-type==0.6.6 \ + --hash=sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c \ + --hash=sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2 + # via pytest-bdd pathspec==0.12.1 \ --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \ --hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712 # via -r reboot/requirements.in +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest propcache==0.4.1 \ --hash=sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e \ --hash=sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4 \ @@ -1211,6 +1242,10 @@ pydantic-settings==2.12.0 \ --hash=sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0 \ --hash=sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809 # via mcp +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c + # via pytest pyjwt[crypto]==2.10.1 \ --hash=sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953 \ --hash=sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb @@ -1225,6 +1260,16 @@ pyright[nodejs]==1.1.411 \ --hash=sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998 \ --hash=sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9 # via -r reboot/requirements.in +pytest==8.4.2 \ + --hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \ + --hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79 + # via + # -r reboot/requirements.in + # pytest-bdd +pytest-bdd==8.1.0 \ + --hash=sha256:2124051e71a05ad7db15296e39013593f72ebf96796e1b023a40e5453c47e5fb \ + --hash=sha256:ef0896c5cd58816dc49810e8ff1d632f4a12019fb3e49959b2d349ffc1c9bfb5 + # via -r reboot/requirements.in python-dateutil==2.8.2 \ --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \ --hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9 @@ -1430,6 +1475,7 @@ six==1.16.0 \ --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 # via # kubernetes-asyncio + # parse-type # python-dateutil sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ @@ -1450,6 +1496,55 @@ tabulate==0.9.0 \ --hash=sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c \ --hash=sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f # via -r reboot/requirements.in +tomli==2.4.1 \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via pytest types-protobuf==4.24.0.20240129 \ --hash=sha256:23be68cc29f3f5213b5c5878ac0151706182874040e220cfb11336f9ee642ead \ --hash=sha256:8a83dd3b9b76a33e08d8636c5daa212ace1396418ed91837635fcd564a624891 @@ -1467,6 +1562,7 @@ typing-extensions==4.15.0 \ # pydantic # pydantic-core # pyright + # pytest-bdd # referencing # typing-inspection # uvicorn diff --git a/tests/reboot/BUILD.bazel b/tests/reboot/BUILD.bazel index f3771cf72..0348d7ff7 100644 --- a/tests/reboot/BUILD.bazel +++ b/tests/reboot/BUILD.bazel @@ -893,6 +893,7 @@ exports_files([ "greeter_rbt.golden.py", "index.html.j2", "ping_api_rbt.golden.py", + "pytest_main.py", ]) js_reboot_web_library( diff --git a/tests/reboot/bdd/BUILD.bazel b/tests/reboot/bdd/BUILD.bazel new file mode 100644 index 000000000..9a42e1a04 --- /dev/null +++ b/tests/reboot/bdd/BUILD.bazel @@ -0,0 +1,58 @@ +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") +load("@rbt_pypi//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_library", "py_test") +load("//reboot:rules.bzl", "py_reboot_library") + +proto_library( + name = "account_proto", + srcs = [":account.proto"], + deps = [ + "//rbt/v1alpha1:options_proto", + ], +) + +py_reboot_library( + name = "account_py_reboot", + proto = "account.proto", + proto_library = ":account_proto", + visibility = ["//tests/reboot/bdd:__subpackages__"], +) + +py_library( + name = "account_servicer_py", + srcs = [":account_servicer.py"], + srcs_version = "PY3", + deps = [ + ":account_py_reboot", + "//reboot/aio:contexts_py", + "//reboot/aio/auth:authorizers_py", + ], +) + +py_test( + name = "bdd_tests_py", + srcs = [ + ":bdd_tests.py", + ":collision_tests.py", + ":conftest.py", + "//tests/reboot:pytest_main.py", + ], + args = [ + "tests/reboot/bdd/bdd_tests.py", + "tests/reboot/bdd/collision_tests.py", + "-v", + ], + data = [ + ":accounts.feature", + ":collisions.feature", + ], + main = "pytest_main.py", + deps = [ + requirement("pytest"), + requirement("pytest-bdd"), + ":account_servicer_py", + "//reboot/aio:applications_py", + "//reboot/bdd:steps_py", + "//tests/reboot/bdd/other:account_servicer_py", + ], +) diff --git a/tests/reboot/bdd/account.proto b/tests/reboot/bdd/account.proto new file mode 100644 index 000000000..d81ca1ee7 --- /dev/null +++ b/tests/reboot/bdd/account.proto @@ -0,0 +1,71 @@ +syntax = "proto3"; + +package tests.reboot.bdd; + +import "rbt/v1alpha1/options.proto"; + +// A bank account, used to test the `reboot.bdd` steps. +message Account { + option (rbt.v1alpha1.state) = { + }; + int64 balance = 1; +} + +service AccountMethods { + rpc Open(OpenRequest) returns (OpenResponse) { + option (rbt.v1alpha1.method).writer = { + constructor: {}, + }; + } + + rpc Balance(BalanceRequest) returns (BalanceResponse) { + option (rbt.v1alpha1.method).reader = { + }; + } + + rpc Deposit(DepositRequest) returns (DepositResponse) { + option (rbt.v1alpha1.method).writer = { + }; + } + + rpc Withdraw(WithdrawRequest) returns (WithdrawResponse) { + option (rbt.v1alpha1.method) = { + writer: {}, + errors: [ "OverdraftError" ], + }; + } +} + +message OpenRequest { + int64 initial_balance = 1; +} + +message OpenResponse {} + +message BalanceRequest {} + +message BalanceResponse { + int64 balance = 1; +} + +message DepositRequest { + int64 amount = 1; +} + +message DepositResponse { + int64 updated_balance = 1; +} + +message WithdrawRequest { + int64 amount = 1; +} + +message WithdrawResponse { + int64 updated_balance = 1; +} + +// Error returned when a withdrawal would overdraft the account. +message OverdraftError { + // Amount the withdrawal would have overdrafted the account by. + int64 amount = 1; +} diff --git a/tests/reboot/bdd/account_servicer.py b/tests/reboot/bdd/account_servicer.py new file mode 100644 index 000000000..ac7325428 --- /dev/null +++ b/tests/reboot/bdd/account_servicer.py @@ -0,0 +1,58 @@ +"""The `Account` servicer that the `reboot.bdd` tests bring up.""" + +from reboot.aio.auth.authorizers import allow +from reboot.aio.contexts import ReaderContext, WriterContext +from tests.reboot.bdd.account_pb2 import OverdraftError +from tests.reboot.bdd.account_rbt import ( + Account, + BalanceRequest, + BalanceResponse, + DepositRequest, + DepositResponse, + OpenRequest, + OpenResponse, + WithdrawRequest, + WithdrawResponse, +) + + +class AccountServicer(Account.Servicer): + + def authorizer(self): + return allow() + + async def open( + self, + context: WriterContext, + request: OpenRequest, + ) -> OpenResponse: + self.state.balance = request.initial_balance + return OpenResponse() + + async def balance( + self, + context: ReaderContext, + request: BalanceRequest, + ) -> BalanceResponse: + return BalanceResponse(balance=self.state.balance) + + async def deposit( + self, + context: WriterContext, + request: DepositRequest, + ) -> DepositResponse: + self.state.balance += request.amount + return DepositResponse(updated_balance=self.state.balance) + + async def withdraw( + self, + context: WriterContext, + request: WithdrawRequest, + ) -> WithdrawResponse: + updated_balance = self.state.balance - request.amount + if updated_balance < 0: + raise Account.WithdrawAborted( + OverdraftError(amount=-updated_balance) + ) + self.state.balance = updated_balance + return WithdrawResponse(updated_balance=updated_balance) diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature new file mode 100644 index 000000000..a281073de --- /dev/null +++ b/tests/reboot/bdd/accounts.feature @@ -0,0 +1,28 @@ +Feature: Accounts + + Background: + Given the application is up + + 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 response has `updated_balance=150` + And `balance` on the `Account` for "alice" has `balance=150` + + Scenario: Withdrawing more than the balance is refused + Given an `Account` for "bob" gets created via `open` + And the `Account` for "bob" gets a `deposit` with `amount=30` + When the `Account` for "bob" attempts a `withdraw` with `amount=50` + Then the attempt aborts with `OverdraftError` where `amount=20` + And `balance` on the `Account` for "bob" has `balance=30` + + Scenario: Custom async steps share the application + Given an `Account` for "carol" gets created via `open` with `initial_balance=10` + When "carol" makes 3 deposits of 7 + Then `balance` on the `Account` for "carol" has `balance=31` + + Scenario: Steps can share one context + Given a shared context + And an `Account` for "dave" gets created via `open` + When the `Account` for "dave" gets a `deposit` with `amount=5` + Then `balance` on the `Account` for "dave" has `balance=5` diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py new file mode 100644 index 000000000..d313553c1 --- /dev/null +++ b/tests/reboot/bdd/bdd_tests.py @@ -0,0 +1,32 @@ +"""Tests of the `reboot.bdd` built-in steps and fixtures, driven by +the scenarios in `accounts.feature`.""" + +# The star import below is how a test module gets the built-in steps +# and their fixtures, but 'ruff' doesn't like it, so we need to +# silence their error. +# +# ruff: noqa: F403 + +from pytest_bdd import parsers, scenarios +from reboot.bdd import when +from reboot.bdd.fixtures import World +from reboot.bdd.steps import * +from tests.reboot.bdd.account_rbt import Account + + +# A custom `async def` step, the way a developer would write one: it +# runs on the same event loop as the built-in steps and can call the +# generated code directly. +@when(parsers.parse('"{state_id}" makes {count:d} deposits of {amount:d}')) +async def _makes_deposits( + world: World, + state_id: str, + count: int, + amount: int, +) -> None: + context = world.context() + for _ in range(count): + await Account.ref(state_id).deposit(context, amount=amount) + + +scenarios('accounts.feature') diff --git a/tests/reboot/bdd/collision_tests.py b/tests/reboot/bdd/collision_tests.py new file mode 100644 index 000000000..b64ba97c8 --- /dev/null +++ b/tests/reboot/bdd/collision_tests.py @@ -0,0 +1,41 @@ +"""Tests of `reboot.bdd` resolving colliding state type names, driven +by the scenarios in `collisions.feature`.""" + +# The star import below is how a test module gets the built-in steps +# and their fixtures, but 'ruff' doesn't like it, so we need to +# silence their error. +# +# ruff: noqa: F403 + +import pytest +from pytest_bdd import scenarios +from reboot.aio.applications import Application +from reboot.bdd.fixtures import World +from reboot.bdd.steps import * +from tests.reboot.bdd.account_rbt import Account +from tests.reboot.bdd.account_servicer import AccountServicer +from tests.reboot.bdd.other.account_rbt import Account as OtherAccount +from tests.reboot.bdd.other.account_servicer import \ + AccountServicer as OtherAccountServicer + + +@pytest.fixture +def application() -> Application: + return Application(servicers=[AccountServicer, OtherAccountServicer]) + + +def test_ambiguous_unqualified_name() -> None: + world = World( + client_types={ + 'tests.reboot.bdd.Account': Account, + 'tests.reboot.bdd.other.Account': OtherAccount, + } + ) + with pytest.raises(ValueError) as raised: + world.client_type('Account') + assert 'names more than one' in str(raised.value) + assert '`tests.reboot.bdd.Account`' in str(raised.value) + assert '`tests.reboot.bdd.other.Account`' in str(raised.value) + + +scenarios('collisions.feature') diff --git a/tests/reboot/bdd/collisions.feature b/tests/reboot/bdd/collisions.feature new file mode 100644 index 000000000..ea66c2631 --- /dev/null +++ b/tests/reboot/bdd/collisions.feature @@ -0,0 +1,10 @@ +Feature: Colliding state type names + + Background: + Given the application is up + + Scenario: Full state type names disambiguate + Given a `tests.reboot.bdd.Account` for "alice" gets created via `open` with `initial_balance=1` + And a `tests.reboot.bdd.other.Account` for "alice" gets created via `open` with `initial_total=2` + Then `balance` on the `tests.reboot.bdd.Account` for "alice" has `balance=1` + And `total` on the `tests.reboot.bdd.other.Account` for "alice" has `total=2` diff --git a/tests/reboot/bdd/conftest.py b/tests/reboot/bdd/conftest.py new file mode 100644 index 000000000..1ebbe7181 --- /dev/null +++ b/tests/reboot/bdd/conftest.py @@ -0,0 +1,10 @@ +"""Fixtures for the `reboot.bdd` tests.""" + +import pytest +from reboot.aio.applications import Application +from tests.reboot.bdd.account_servicer import AccountServicer + + +@pytest.fixture +def application() -> Application: + return Application(servicers=[AccountServicer]) diff --git a/tests/reboot/bdd/other/BUILD.bazel b/tests/reboot/bdd/other/BUILD.bazel new file mode 100644 index 000000000..9802fb9c7 --- /dev/null +++ b/tests/reboot/bdd/other/BUILD.bazel @@ -0,0 +1,30 @@ +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") +load("@rules_python//python:defs.bzl", "py_library") +load("//reboot:rules.bzl", "py_reboot_library") + +proto_library( + name = "account_proto", + srcs = [":account.proto"], + deps = [ + "//rbt/v1alpha1:options_proto", + ], +) + +py_reboot_library( + name = "account_py_reboot", + proto = "account.proto", + proto_library = ":account_proto", + visibility = ["//tests/reboot/bdd:__subpackages__"], +) + +py_library( + name = "account_servicer_py", + srcs = [":account_servicer.py"], + srcs_version = "PY3", + visibility = ["//tests/reboot/bdd:__subpackages__"], + deps = [ + ":account_py_reboot", + "//reboot/aio:contexts_py", + "//reboot/aio/auth:authorizers_py", + ], +) diff --git a/tests/reboot/bdd/other/account.proto b/tests/reboot/bdd/other/account.proto new file mode 100644 index 000000000..49bfc1f55 --- /dev/null +++ b/tests/reboot/bdd/other/account.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package tests.reboot.bdd.other; + +import "rbt/v1alpha1/options.proto"; + +// A second state type named `Account`, so that the `reboot.bdd` +// tests can exercise resolving colliding state type names. +message Account { + option (rbt.v1alpha1.state) = { + }; + int64 total = 1; +} + +service AccountMethods { + rpc Open(OpenRequest) returns (OpenResponse) { + option (rbt.v1alpha1.method).writer = { + constructor: {}, + }; + } + + rpc Total(TotalRequest) returns (TotalResponse) { + option (rbt.v1alpha1.method).reader = { + }; + } +} + +message OpenRequest { + int64 initial_total = 1; +} + +message OpenResponse {} + +message TotalRequest {} + +message TotalResponse { + int64 total = 1; +} diff --git a/tests/reboot/bdd/other/account_servicer.py b/tests/reboot/bdd/other/account_servicer.py new file mode 100644 index 000000000..a42d08497 --- /dev/null +++ b/tests/reboot/bdd/other/account_servicer.py @@ -0,0 +1,33 @@ +"""The servicer of the `Account` state type whose unqualified name +collides with `tests.reboot.bdd.Account` in the `reboot.bdd` tests.""" + +from reboot.aio.auth.authorizers import allow +from reboot.aio.contexts import ReaderContext, WriterContext +from tests.reboot.bdd.other.account_rbt import ( + Account, + OpenRequest, + OpenResponse, + TotalRequest, + TotalResponse, +) + + +class AccountServicer(Account.Servicer): + + def authorizer(self): + return allow() + + async def open( + self, + context: WriterContext, + request: OpenRequest, + ) -> OpenResponse: + self.state.total = request.initial_total + return OpenResponse() + + async def total( + self, + context: ReaderContext, + request: TotalRequest, + ) -> TotalResponse: + return TotalResponse(total=self.state.total) diff --git a/tests/reboot/pytest_main.py b/tests/reboot/pytest_main.py new file mode 100644 index 000000000..31efbb9dc --- /dev/null +++ b/tests/reboot/pytest_main.py @@ -0,0 +1,14 @@ +"""Runs pytest on the paths given as arguments. + +The entry point for `py_test` targets whose tests need pytest to be +the importer of their test modules, e.g. pytest-bdd's `scenarios()` +only works in a module that pytest imports, so running such a module +directly as `__main__` would fail before pytest is configured. List +the test files in the target's `args`. +""" + +import pytest +import sys + +if __name__ == '__main__': + sys.exit(pytest.main(sys.argv[1:] + ['-p', 'no:cacheprovider'])) From 35fa7fab64fa744011cbdd733c4e26bdddce8cc0 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Wed, 2 Sep 2026 03:03:11 +0000 Subject: [PATCH 03/42] Test `reboot.bdd` against a pydantic API `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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- mypy.ini | 3 + tests/reboot/bdd/pydantic/BUILD.bazel | 53 ++++++++++++++ tests/reboot/bdd/pydantic/account_api.py | 70 +++++++++++++++++++ tests/reboot/bdd/pydantic/account_servicer.py | 55 +++++++++++++++ tests/reboot/bdd/pydantic/accounts.feature | 22 ++++++ tests/reboot/bdd/pydantic/bdd_tests.py | 35 ++++++++++ tests/reboot/bdd/pydantic/conftest.py | 10 +++ 7 files changed, 248 insertions(+) create mode 100644 tests/reboot/bdd/pydantic/BUILD.bazel create mode 100644 tests/reboot/bdd/pydantic/account_api.py create mode 100644 tests/reboot/bdd/pydantic/account_servicer.py create mode 100644 tests/reboot/bdd/pydantic/accounts.feature create mode 100644 tests/reboot/bdd/pydantic/bdd_tests.py create mode 100644 tests/reboot/bdd/pydantic/conftest.py diff --git a/mypy.ini b/mypy.ini index e3ba671b3..d956de329 100644 --- a/mypy.ini +++ b/mypy.ini @@ -158,6 +158,9 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pydantic_core.*] ignore_missing_imports = True +[mypy-tests.reboot.bdd.pydantic.*] +ignore_missing_imports = True +disable_error_code = attr-defined [mypy-tests.reboot.pydantic.*] ignore_missing_imports = True disable_error_code = attr-defined diff --git a/tests/reboot/bdd/pydantic/BUILD.bazel b/tests/reboot/bdd/pydantic/BUILD.bazel new file mode 100644 index 000000000..818605c6a --- /dev/null +++ b/tests/reboot/bdd/pydantic/BUILD.bazel @@ -0,0 +1,53 @@ +load("@rbt_pypi//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_library", "py_test") +load("//reboot:pydantic_to_proto.bzl", "py_reboot_library_from_pydantic") + +py_library( + name = "account_api_py", + srcs = ["account_api.py"], + deps = [ + "//reboot:api_py", + ], +) + +py_reboot_library_from_pydantic( + name = "account_py_reboot", + py_deps = [ + ":account_api_py", + ], + pydantic = ":account_api.py", +) + +py_library( + name = "account_servicer_py", + srcs = [":account_servicer.py"], + srcs_version = "PY3", + deps = [ + ":account_api_py", + ":account_py_reboot", + "//reboot/aio:contexts_py", + "//reboot/aio/auth:authorizers_py", + ], +) + +py_test( + name = "bdd_tests_py", + srcs = [ + ":bdd_tests.py", + ":conftest.py", + "//tests/reboot:pytest_main.py", + ], + args = [ + "tests/reboot/bdd/pydantic/bdd_tests.py", + "-v", + ], + data = [":accounts.feature"], + main = "pytest_main.py", + deps = [ + requirement("pytest"), + requirement("pytest-bdd"), + ":account_servicer_py", + "//reboot/aio:applications_py", + "//reboot/bdd:steps_py", + ], +) diff --git a/tests/reboot/bdd/pydantic/account_api.py b/tests/reboot/bdd/pydantic/account_api.py new file mode 100644 index 000000000..811757c16 --- /dev/null +++ b/tests/reboot/bdd/pydantic/account_api.py @@ -0,0 +1,70 @@ +"""The pydantic API of the `Account` state type that the `reboot.bdd` +tests run against.""" + +from reboot.api import API, Field, Methods, Model, Reader, Type, Writer + + +class State(Model): + balance: int = Field(tag=1, default=0) + + +class OpenRequest(Model): + initial_balance: int = Field(tag=1, default=0) + + +class DepositRequest(Model): + amount: int = Field(tag=1) + + +class DepositResponse(Model): + updated_balance: int = Field(tag=1) + + +class WithdrawRequest(Model): + amount: int = Field(tag=1) + + +class WithdrawResponse(Model): + updated_balance: int = Field(tag=1) + + +class BalanceResponse(Model): + balance: int = Field(tag=1) + + +class OverdraftError(Model): + # Amount the withdrawal would have overdrafted the account by. + amount: int = Field(tag=1) + + +AccountMethods = Methods( + open=Writer( + request=OpenRequest, + response=None, + factory=True, + mcp=None, + ), + deposit=Writer( + request=DepositRequest, + response=DepositResponse, + mcp=None, + ), + withdraw=Writer( + request=WithdrawRequest, + response=WithdrawResponse, + errors=[OverdraftError], + mcp=None, + ), + balance=Reader( + request=None, + response=BalanceResponse, + mcp=None, + ), +) + +api = API( + Account=Type( + state=State, + methods=AccountMethods, + ), +) diff --git a/tests/reboot/bdd/pydantic/account_servicer.py b/tests/reboot/bdd/pydantic/account_servicer.py new file mode 100644 index 000000000..c2292908e --- /dev/null +++ b/tests/reboot/bdd/pydantic/account_servicer.py @@ -0,0 +1,55 @@ +"""The pydantic `Account` servicer that the `reboot.bdd` tests bring +up.""" + +from reboot.aio.auth.authorizers import allow +from reboot.aio.contexts import ReaderContext, WriterContext +from tests.reboot.bdd.pydantic.account_api import ( + BalanceResponse, + DepositRequest, + DepositResponse, + OpenRequest, + OverdraftError, + WithdrawRequest, + WithdrawResponse, +) +from tests.reboot.bdd.pydantic.account_api_rbt import Account + + +class AccountServicer(Account.Servicer): + + def authorizer(self): + return allow() + + async def open( + self, + context: WriterContext, + request: OpenRequest, + ) -> None: + self.state.balance = request.initial_balance + + async def deposit( + self, + context: WriterContext, + request: DepositRequest, + ) -> DepositResponse: + self.state.balance += request.amount + return DepositResponse(updated_balance=self.state.balance) + + async def withdraw( + self, + context: WriterContext, + request: WithdrawRequest, + ) -> WithdrawResponse: + updated_balance = self.state.balance - request.amount + if updated_balance < 0: + raise Account.WithdrawAborted( + OverdraftError(amount=-updated_balance) + ) + self.state.balance = updated_balance + return WithdrawResponse(updated_balance=updated_balance) + + async def balance( + self, + context: ReaderContext, + ) -> BalanceResponse: + return BalanceResponse(balance=self.state.balance) diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature new file mode 100644 index 000000000..2e317b740 --- /dev/null +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -0,0 +1,22 @@ +Feature: Accounts with a pydantic API + + Background: + Given the application is up + + 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 response has `updated_balance=150` + And `balance` on the `Account` for "alice" has `balance=150` + + Scenario: Withdrawing more than the balance is refused + Given an `Account` for "bob" gets created via `open` + And the `Account` for "bob" gets a `deposit` with `amount=30` + When the `Account` for "bob" attempts a `withdraw` with `amount=50` + Then the attempt aborts with `OverdraftError` where `amount=20` + And `balance` on the `Account` for "bob" has `balance=30` + + Scenario: Custom async steps can call through the world + Given an `Account` for "carol" gets created via `open` with `initial_balance=10` + When "carol" makes 3 deposits of 7 + Then `balance` on the `Account` for "carol" has `balance=31` diff --git a/tests/reboot/bdd/pydantic/bdd_tests.py b/tests/reboot/bdd/pydantic/bdd_tests.py new file mode 100644 index 000000000..c654036c8 --- /dev/null +++ b/tests/reboot/bdd/pydantic/bdd_tests.py @@ -0,0 +1,35 @@ +"""Tests of the `reboot.bdd` built-in steps against a pydantic API, +driven by the scenarios in `accounts.feature`.""" + +# The star import below is how a test module gets the built-in steps +# and their fixtures, but 'ruff' doesn't like it, so we need to +# silence their error. +# +# ruff: noqa: F403 + +from pytest_bdd import parsers, scenarios +from reboot.bdd import when +from reboot.bdd.fixtures import World +from reboot.bdd.steps import * + + +# A custom `async def` step, the way a developer would write one: it +# calls through `World.call()` rather than importing the generated +# code. +@when(parsers.parse('"{state_id}" makes {count:d} deposits of {amount:d}')) +async def _makes_deposits( + world: World, + state_id: str, + count: int, + amount: int, +) -> None: + for _ in range(count): + await world.call( + state_type='Account', + state_id=state_id, + method='deposit', + properties={'amount': amount}, + ) + + +scenarios('accounts.feature') diff --git a/tests/reboot/bdd/pydantic/conftest.py b/tests/reboot/bdd/pydantic/conftest.py new file mode 100644 index 000000000..08df8e4b9 --- /dev/null +++ b/tests/reboot/bdd/pydantic/conftest.py @@ -0,0 +1,10 @@ +"""Fixtures for the pydantic `reboot.bdd` tests.""" + +import pytest +from reboot.aio.applications import Application +from tests.reboot.bdd.pydantic.account_servicer import AccountServicer + + +@pytest.fixture +def application() -> Application: + return Application(servicers=[AccountServicer]) From 3014cc88ae13381a90e39b9718e2b5ea3f402669 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Wed, 2 Sep 2026 03:21:24 +0000 Subject: [PATCH 04/42] Let `reboot.bdd` steps save response properties Six of the example test suites take a value out of one response, e.g. an account or order ID, and use it in the next call. In Gherkin, a 'has' or 'where' list is a list of clauses: a comparing clause asserts, and a saving clause saves a property under a name: When `get_owner` on the `Account` for "frank" has `owner.name` saved as "$owner_name" A Then 'has' or 'where' asserts and refuses saving clauses; a Given or When 'has' saves and refuses comparing clauses. Later steps say `$name` to use a saved value, in a state's ID or as a property value; a quoted "$name" stays the literal string, and saved values are used as-is, so a saved message can be passed straight into a later call's properties. Readers are only read via '`reader` on ...', and a reader's abort gets its own assertion, 'aborts with `SomeError` where ...'; 'gets a' and 'attempts a' refuse readers, pointing at those steps, the way they refuse writers. 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. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/BUILD.bazel | 1 + reboot/bdd/fixtures.py | 39 ++- reboot/bdd/steps.py | 373 ++++++++++++++++++--- tests/reboot/bdd/account.proto | 5 +- tests/reboot/bdd/account_servicer.py | 2 +- tests/reboot/bdd/accounts.feature | 21 +- tests/reboot/bdd/bdd_tests.py | 19 ++ tests/reboot/bdd/pydantic/accounts.feature | 2 +- 8 files changed, 405 insertions(+), 57 deletions(-) diff --git a/reboot/bdd/BUILD.bazel b/reboot/bdd/BUILD.bazel index b4b8cd321..78578f0f8 100644 --- a/reboot/bdd/BUILD.bazel +++ b/reboot/bdd/BUILD.bazel @@ -38,6 +38,7 @@ py_library( deps = [ ":__init___py", requirement("pytest"), + "//reboot:api_py", "//reboot/aio:aborted_py", "//reboot/aio:applications_py", "//reboot/aio:external_py", diff --git a/reboot/bdd/fixtures.py b/reboot/bdd/fixtures.py index cf2e64e53..115533577 100644 --- a/reboot/bdd/fixtures.py +++ b/reboot/bdd/fixtures.py @@ -2,11 +2,14 @@ import pytest from dataclasses import dataclass, field +from google.protobuf import json_format +from google.protobuf.message import Message from reboot.aio.aborted import Aborted from reboot.aio.external import ExternalContext from reboot.aio.tests import Reboot +from reboot.api import Model from reboot.bdd.loop import EventLoopThread, start_event_loop, stop_event_loop -from typing import Any, Callable, Iterator, Optional +from typing import Any, Callable, Iterator, Optional, Union @pytest.fixture(autouse=True) @@ -34,6 +37,27 @@ def rbt(reboot_event_loop: EventLoopThread) -> Iterator[Reboot]: reboot_event_loop.run(reboot.stop()) +# A JSON value: what property values, and the values saved under a +# name, are made of. +JsonValue = Union[None, bool, int, float, str, list['JsonValue'], + dict[str, 'JsonValue']] + + +def _json_object( + message_or_model: Union[Message, Model], +) -> dict[str, 'JsonValue']: + """The given message or model as its JSON object (a message via + its canonical JSON, with fields without presence included, so + every property is reachable).""" + if isinstance(message_or_model, Message): + return json_format.MessageToDict( + message_or_model, + preserving_proto_field_name=True, + always_print_fields_with_no_presence=True, + ) + return message_or_model.model_dump() + + @dataclass class World: """What one scenario's steps have accumulated so far. @@ -67,6 +91,10 @@ class World: # The response of the most recent call a step made. response: Optional[Any] = None + # Values saved under a name, as JSON; later steps say '$name' to + # use. + saved: dict[str, JsonValue] = field(default_factory=dict) + # The error the most recent 'attempts' step's call aborted with, # or `None` if that call succeeded. aborted: Optional[Aborted] = None @@ -127,6 +155,15 @@ def factory( raise ValueError(f"`{state_type}` has no factory `{method}`") return factory + def is_reader(self, *, state_type: str, method: str) -> bool: + """Whether the named method is one of the named state type's + unary readers.""" + # TODO: this is a bit of a hack! We check to see if + # `StateType.reactively().method()` exists to know if it is a + # reader because `reactively` is only for readers. + reference = self.client_type(state_type).ref('is-reader') + return hasattr(reference.reactively(), method) + async def call( self, *, diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index d6d6d3016..e3d0e8448 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -25,6 +25,21 @@ def application() -> Application: When the `Account` for "alice" gets a `deposit` with `amount=50` Then `balance` on the `Account` for "alice" has `balance=50` + +A Then 'has' asserts and a Given or When 'has' saves, and readers +are only read that way: 'gets a' and 'attempts a' refuse readers the +way 'has' refuses writers, and a reader's abort is asserted with +'`reader` on ... aborts with ...'. + +A 'has' or 'where' list can also save a property under a name, +which later steps say as `$name`, in a state's ID or as a property +value (a quoted "$name" stays the literal string): + + When `get_owner` on the `Account` for "frank" has + `owner.name` saved as "$owner_name" + And the resulting `updated_balance` is saved as "$balance" + And the `Account` for "$owner_name" gets a `deposit` with + `amount=1` """ # The step functions below take the `rbt` and `world` @@ -43,7 +58,7 @@ def application() -> Application: from reboot.bdd import given, then, when # Re-exported so that `from reboot.bdd.steps import *` brings in the # fixtures the steps run on. -from reboot.bdd.fixtures import World +from reboot.bdd.fixtures import JsonValue, World, _json_object from reboot.bdd.fixtures import rbt as rbt from reboot.bdd.fixtures import reboot_event_loop as reboot_event_loop from reboot.bdd.fixtures import world as world @@ -55,7 +70,13 @@ def application() -> Application: # being anything up to the closing backtick. _PROPERTY_PATTERN = re.compile(r'`(?P\w+(?:\.\w+)*)=(?P[^`]+)`') -# What separates two properties in step text: a comma, an 'and', or a +# One saving clause in a 'has' or 'where' list: the (possibly +# dotted) property name in backticks, saved under a '$name'. +_SAVE_PATTERN = re.compile( + r'`(?P\w+(?:\.\w+)*)` saved as "\$(?P\w+)"' +) + +# What separates two clauses in step text: a comma, an 'and', or a # comma followed by an 'and'. _SEPARATOR_PATTERN = re.compile(r'\s*(?:,\s*and|,|and)\s+') @@ -67,58 +88,190 @@ def application() -> Application: _PROPERTIES = r'(?: with (?P.+))?' -def _parse_properties(properties: Optional[str]) -> dict[str, Any]: - """Parses a step's property list, e.g. '`amount=50` and - `reason="promo"`', into a dictionary of Python literal values.""" - if properties is None: - return {} - parsed: dict[str, Any] = {} - text = properties.strip() +def _saved_value(world: World, name: str) -> JsonValue: + """The saved value going by the given name; raises if there is + none.""" + if name not in world.saved: + raise ValueError( + f'Nothing saved as "${name}"; saved: ' + + (', '.join(f'"${n}"' for n in sorted(world.saved)) or "nothing") + ) + return world.saved[name] + + +def _resolve_state_id(world: World, state_id: str) -> str: + """The state ID a step names: the saved value when the ID is of + the form '$name', otherwise the ID itself.""" + if not re.fullmatch(r'\$\w+', state_id): + return state_id + value = _saved_value(world, state_id[1:]) + if not isinstance(value, str): + raise ValueError( + f'The value saved as "${state_id[1:]}" must be a string ' + f"to name a state, but it is {value!r}" + ) + return value + + +def _parse_clauses( + world: World, + clauses: Optional[str], +) -> tuple[dict[str, Any], dict[str, str]]: + """Parses a step's clause list into the properties to compare, + e.g. '`amount=50`', and, keyed by the name to save under, the + properties to save, e.g. '`amount` saved as "$amount"'; a + property value of the form '$name' becomes the saved value going + by that name.""" + properties: dict[str, Any] = {} + saves: dict[str, str] = {} + if clauses is None: + return properties, saves + text = clauses.strip() position = 0 while position < len(text): if position > 0: separator = _SEPARATOR_PATTERN.match(text, position) if separator is None: raise ValueError( - "Expected a ',' or 'and' between properties, but " + "Expected a ',' or 'and' between clauses, but " f"got: {text[position:]}" ) position = separator.end() + save_match = _SAVE_PATTERN.match(text, position) + if save_match is not None: + saves[save_match['saved']] = save_match['name'] + position = save_match.end() + continue property_match = _PROPERTY_PATTERN.match(text, position) if property_match is None: raise ValueError( - "Expected a property of the form `name=value`, but " - f"got: {text[position:]}" + "Expected a clause of the form `name=value` or " + "`name` saved as \"$name\", but got: " + f"{text[position:]}" ) try: - value = ast.literal_eval(property_match['value']) + if re.fullmatch(r'\$\w+', property_match['value']): + value = _saved_value(world, property_match['value'][1:]) + else: + value = ast.literal_eval(property_match['value']) except (ValueError, SyntaxError) as error: raise ValueError( f"The value of `{property_match['name']}` must be a Python " "literal, e.g. 50, 2.5, \"text\", or True, but got: " f"{property_match['value']}" ) from error - parsed[property_match['name']] = value + properties[property_match['name']] = value position = property_match.end() + return properties, saves + + +def _parse_properties( + world: World, + properties: Optional[str], +) -> dict[str, Any]: + """Parses a step's property list, e.g. '`amount=50` and + `reason="promo"`', into a dictionary of Python literal values; a + saving clause is refused, it belongs in a 'has' or 'where' + list.""" + parsed, saves = _parse_clauses(world, properties) + if saves: + raise ValueError( + "A property can only be saved from a 'has' or 'where' " + "list, not passed to a call: " + + ', '.join(f'"${name}"' for name in sorted(saves)) + ) return parsed +def _assert_clauses( + world: World, + subject: Any, + clauses: Optional[str], +) -> None: + """Asserts the given clause list against the given response, + state, or error; saving clauses are refused, they belong under + Given or When.""" + properties, saves = _parse_clauses(world, clauses) + if saves: + raise ValueError( + "A Then 'has' or 'where' asserts; save under Given or " + "When instead: " + + ', '.join(f'"${name}"' for name in sorted(saves)) + ) + _assert_properties(subject, properties) + + +def _save_clauses( + world: World, + subject: Any, + clauses: Optional[str], +) -> None: + """Saves the properties the given clause list names from the + given response; comparing clauses are refused, they belong in a + Then.""" + properties, saves = _parse_clauses(world, clauses) + if properties: + raise ValueError( + "A Given or When 'has' saves; assert with a Then " + "instead: " + + ', '.join(f'`{name}`' for name in sorted(properties)) + ) + if not saves: + raise ValueError( + "Expected at least one saving clause, e.g. " + '`name` saved as "$name"' + ) + subject_json = _json_object(subject) + for name, property_name in saves.items(): + world.saved[name] = _resolve_json_property(subject_json, property_name) + + +def _resolve_json_property(json_object: JsonValue, name: str) -> JsonValue: + """The value the (possibly dotted) property name reaches in the + given JSON object; saving walks the response's JSON, rather than + the live response the way asserting does, so that saved values + are canonical JSON.""" + value = json_object + for part in name.split('.'): + if not isinstance(value, dict): + raise AssertionError( + f"Expected an object with a property `{part}` (from " + f"`{name}`), but got: {value!r}" + ) + if part not in value: + raise AssertionError( + f"Expected a property `{part}` (from `{name}`), but " + "there are: " + ( + ', '.join(f'`{n}`' + for n in sorted(value)) or "no properties" + ) + ) + value = value[part] + return value + + +def _resolve_property(subject: Any, name: str) -> Any: + """The value the (possibly dotted) property name reaches on the + given response, state, or error.""" + actual = subject + for attribute in name.split('.'): + try: + actual = getattr(actual, attribute) + except AttributeError as error: + raise AssertionError( + f"Expected `{type(actual).__name__}` to have a " + f"property `{attribute}` (from `{name}`), but it has " + "no such property" + ) from error + return actual + + def _assert_properties(subject: Any, properties: dict[str, Any]) -> None: """Asserts that each of the given (possibly dotted) property names reaches the expected value on the given response, state, or error.""" for name, expected in properties.items(): - actual = subject - for attribute in name.split('.'): - try: - actual = getattr(actual, attribute) - except AttributeError as error: - raise AssertionError( - f"Expected `{type(actual).__name__}` to have a " - f"property `{attribute}` (from " - f"`{name}={expected!r}`), but it has no such " - "property" - ) from error + actual = _resolve_property(subject, name) assert actual == expected, ( f"Expected `{name}` to be {expected!r}, but it is {actual!r}" ) @@ -158,7 +311,8 @@ async def _gets_created_via( factory = world.factory(state_type=state_type, method=method) try: _, world.response = await factory( - world.context(), state_id, **_parse_properties(properties) + world.context(), _resolve_state_id(world, state_id), + **_parse_properties(world, properties) ) except Aborted as aborted: raise AssertionError( @@ -176,12 +330,17 @@ async def _gets_a( method: str, properties: Optional[str], ) -> None: + if world.is_reader(state_type=state_type, method=method): + raise ValueError( + f"`{method}` is a reader; read it with " + f"'`{method}` on the `{state_type}` for \"...\" has ...'" + ) try: world.response = await world.call( state_type=state_type, - state_id=state_id, + state_id=_resolve_state_id(world, state_id), method=method, - properties=_parse_properties(properties), + properties=_parse_properties(world, properties), ) except Aborted as aborted: raise AssertionError( @@ -200,72 +359,182 @@ async def _attempts_a( method: str, properties: Optional[str], ) -> None: + if world.is_reader(state_type=state_type, method=method): + raise ValueError( + f"`{method}` is a reader; assert its abort with " + f"'`{method}` on the `{state_type}` for \"...\" aborts " + "with ...'" + ) try: world.response = await world.call( state_type=state_type, - state_id=state_id, + state_id=_resolve_state_id(world, state_id), method=method, - properties=_parse_properties(properties), + properties=_parse_properties(world, properties), ) world.aborted = None except Aborted as aborted: world.aborted = aborted +def _assert_aborted( + world: World, + aborted: Aborted, + error_type: str, + clauses: Optional[str], +) -> None: + """Asserts that the given abort's error is of the named type and + satisfies the given 'where' clauses.""" + error = aborted.error + assert type(error).__name__ == error_type, ( + f"Expected an abort with `{error_type}`, but it aborted " + f"with `{type(error).__name__}`: {aborted}" + ) + _assert_clauses(world, error, clauses) + + @then( parsers.re( r'the attempt aborts with `(?P\w+)`' - r'(?: where (?P.+))?$' + r'(?: where (?P.+))?$' ) ) def _the_attempt_aborts_with( world: World, error_type: str, - properties: Optional[str], + clauses: Optional[str], ) -> None: assert world.aborted is not None, ( "Expected the most recent 'attempts' step to have aborted, " "but it succeeded" ) - error = world.aborted.error - assert type(error).__name__ == error_type, ( - f"Expected the attempt to have aborted with `{error_type}`, " - f"but it aborted with `{type(error).__name__}`: " - f"{world.aborted}" - ) - _assert_properties(error, _parse_properties(properties)) + _assert_aborted(world, world.aborted, error_type, clauses) -@then( - parsers.re(rf'`(?P\w+)` on {_STATE} ' - r'has (?P.+)$') -) -async def _has( +async def _read( world: World, method: str, state_type: str, state_id: str, - properties: str, -) -> None: +) -> Any: + """Calls the named reader on the named state, recording and + returning its response; raises if the method is not a reader.""" + if not world.is_reader(state_type=state_type, method=method): + raise ValueError( + f"`{method}` is not a reader; call it with 'the " + f"`{state_type}` for \"...\" gets a `{method}`'" + ) try: - response = await world.call( + world.response = await world.call( state_type=state_type, - state_id=state_id, + state_id=_resolve_state_id(world, state_id), method=method, properties={}, ) + return world.response except Aborted as aborted: raise AssertionError( f"`{method}` on the `{state_type}` for \"{state_id}\" " f"{aborted}" ) from aborted - _assert_properties(response, _parse_properties(properties)) -@then(parsers.re(r'the response has (?P.+)$')) -def _the_response_has(world: World, properties: str) -> None: +@then(parsers.re(rf'`(?P\w+)` on {_STATE} ' + r'has (?P.+)$')) +async def _then_has( + world: World, + method: str, + state_type: str, + state_id: str, + clauses: str, +) -> None: + response = await _read(world, method, state_type, state_id) + _assert_clauses(world, response, clauses) + + +@given(parsers.re(rf'`(?P\w+)` on {_STATE} ' + r'has (?P.+)$')) +@when(parsers.re(rf'`(?P\w+)` on {_STATE} ' + r'has (?P.+)$')) +async def _when_has( + world: World, + method: str, + state_type: str, + state_id: str, + clauses: str, +) -> None: + response = await _read(world, method, state_type, state_id) + _save_clauses(world, response, clauses) + + +@then( + parsers.re( + rf'`(?P\w+)` on {_STATE} ' + r'aborts with `(?P\w+)`' + r'(?: where (?P.+))?$' + ) +) +async def _aborts_with( + world: World, + method: str, + state_type: str, + state_id: str, + error_type: str, + clauses: Optional[str], +) -> None: + if not world.is_reader(state_type=state_type, method=method): + raise ValueError( + f"`{method}` is not a reader; assert its abort with " + f"'attempts a `{method}`' and 'the attempt aborts with " + "...'" + ) + try: + await world.call( + state_type=state_type, + state_id=_resolve_state_id(world, state_id), + method=method, + properties={}, + ) + except Aborted as aborted: + _assert_aborted(world, aborted, error_type, clauses) + return + raise AssertionError( + f"Expected `{method}` on the `{state_type}` for " + f'"{state_id}" to abort with `{error_type}`, but it ' + "succeeded" + ) + + +@then(parsers.re(r'the result has (?P.+)$')) +def _the_result_has(world: World, clauses: str) -> None: assert world.response is not None, ( "Expected a preceding step to have made a call that returned " "a response, but there is none" ) - _assert_properties(world.response, _parse_properties(properties)) + _assert_clauses(world, world.response, clauses) + + +@given( + parsers.re( + r'the resulting `(?P\w+(?:\.\w+)*)` ' + r'is saved as "\$(?P\w+)"$' + ) +) +@when( + parsers.re( + r'the resulting `(?P\w+(?:\.\w+)*)` ' + r'is saved as "\$(?P\w+)"$' + ) +) +def _the_resulting_property_is_saved_as( + world: World, + property_name: str, + name: str, +) -> None: + assert world.response is not None, ( + "Expected a preceding step to have made a call that returned " + "a response, but there is none" + ) + world.saved[name] = _resolve_json_property( + _json_object(world.response), property_name + ) diff --git a/tests/reboot/bdd/account.proto b/tests/reboot/bdd/account.proto index d81ca1ee7..735c0d871 100644 --- a/tests/reboot/bdd/account.proto +++ b/tests/reboot/bdd/account.proto @@ -40,7 +40,10 @@ message OpenRequest { int64 initial_balance = 1; } -message OpenResponse {} +message OpenResponse { + // ID of the newly opened account. + string account_id = 1; +} message BalanceRequest {} diff --git a/tests/reboot/bdd/account_servicer.py b/tests/reboot/bdd/account_servicer.py index ac7325428..b2b8d5792 100644 --- a/tests/reboot/bdd/account_servicer.py +++ b/tests/reboot/bdd/account_servicer.py @@ -27,7 +27,7 @@ async def open( request: OpenRequest, ) -> OpenResponse: self.state.balance = request.initial_balance - return OpenResponse() + return OpenResponse(account_id=context.state_id) async def balance( self, diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index a281073de..83f0ea84d 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -6,7 +6,7 @@ Feature: Accounts 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 response has `updated_balance=150` + Then the result has `updated_balance=150` And `balance` on the `Account` for "alice" has `balance=150` Scenario: Withdrawing more than the balance is refused @@ -21,6 +21,25 @@ Feature: Accounts When "carol" makes 3 deposits of 7 Then `balance` on the `Account` for "carol" has `balance=31` + Scenario: Steps can save result properties + Given an `Account` for "eve" gets created via `open` with `initial_balance=9` + And the resulting `account_id` is saved as "$eve_account" + When the `Account` for "$eve_account" gets a `deposit` with `amount=1` + And the resulting `updated_balance` is saved as "$balance" + And the `Account` for "$eve_account" gets a `deposit` with `amount=$balance` + When `balance` on the `Account` for "$eve_account" has `balance` saved as "$current" + And the `Account` for "$eve_account" gets a `deposit` with `amount=$current` + Then `balance` on the `Account` for "$eve_account" has `balance=40` + + Scenario: Saving during setup + Given an `Account` for "gus" gets created via `open` with `initial_balance=7` + And `balance` on the `Account` for "gus" has `balance` saved as "$initial" and `balance` saved as "$twin" + When the `Account` for "gus" gets a `deposit` with `amount=$initial` + Then `balance` on the `Account` for "gus" has `balance=14` + + Scenario: Readers can abort + Then `balance` on the `Account` for "ghost" aborts with `StateNotConstructed` + Scenario: Steps can share one context Given a shared context And an `Account` for "dave" gets created via `open` diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index d313553c1..9b396b9c2 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -7,10 +7,13 @@ # # ruff: noqa: F403 +import pytest from pytest_bdd import parsers, scenarios from reboot.bdd import when from reboot.bdd.fixtures import World from reboot.bdd.steps import * +from reboot.bdd.steps import _assert_aborted +from tests.reboot.bdd.account_pb2 import OverdraftError from tests.reboot.bdd.account_rbt import Account @@ -29,4 +32,20 @@ async def _makes_deposits( await Account.ref(state_id).deposit(context, amount=amount) +def test_is_reader() -> None: + world = World(client_types={'tests.reboot.bdd.Account': Account}) + assert world.is_reader(state_type='Account', method='balance') + assert not world.is_reader(state_type='Account', method='deposit') + + +def test_assert_aborted_where() -> None: + world = World() + aborted = Account.WithdrawAborted(OverdraftError(amount=20)) + _assert_aborted(world, aborted, 'OverdraftError', '`amount=20`') + with pytest.raises(AssertionError): + _assert_aborted(world, aborted, 'OverdraftError', '`amount=21`') + with pytest.raises(AssertionError): + _assert_aborted(world, aborted, 'SomeOtherError', None) + + scenarios('accounts.feature') diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index 2e317b740..738832fcb 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -6,7 +6,7 @@ Feature: Accounts with a pydantic API 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 response has `updated_balance=150` + Then the result has `updated_balance=150` And `balance` on the `Account` for "alice" has `balance=150` Scenario: Withdrawing more than the balance is refused From ba7cf573ccbc0220d9c237adcc736c90df44fa6b Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Wed, 2 Sep 2026 03:40:47 +0000 Subject: [PATCH 05/42] Support message-valued properties in `reboot.bdd` Properties now read as `path=value` assignments, each in backticks, the value being JSON with JSON5's leniencies (object keys need no quotes), and an object or array value goes through the named method's request type, looked up via the generated client class's `Request` alias: pydantic requests validate with `model_validate` and proto requests parse with `json_format.ParseDict`, so nested messages, enums by name, and 64-bit integers all follow the JSON semantics those types define: When the `Account` for "frank" gets a `set_owner` with `owner.name="Frankie"` and `owner.tags=["pro"]` A dotted path nests when calling, and a path that collides with another, e.g. both `owner` and `owner.name`, raises. On assertions, an object compares as the complete message the actual value's type parses it as, and an array compares elementwise; a saved property whose value is already a message is merged into the request as-is. Also guards pydantic request validation against its default of ignoring unknown keys, which made a mistyped property a silent no-op. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- mypy.ini | 4 + reboot/bdd/BUILD.bazel | 5 + reboot/bdd/fixtures.py | 224 +++++- reboot/bdd/steps.py | 661 +++++++++++++----- reboot/requirements.in | 2 + reboot/requirements_lock.txt | 8 + tests/reboot/bdd/account.proto | 56 ++ tests/reboot/bdd/account_servicer.py | 38 + tests/reboot/bdd/accounts.feature | 22 + tests/reboot/bdd/bdd_tests.py | 153 +++- tests/reboot/bdd/pydantic/account_api.py | 47 ++ tests/reboot/bdd/pydantic/account_servicer.py | 30 + tests/reboot/bdd/pydantic/accounts.feature | 18 + tests/reboot/bdd/pydantic/bdd_tests.py | 138 +++- 14 files changed, 1206 insertions(+), 200 deletions(-) diff --git a/mypy.ini b/mypy.ini index d956de329..0abd7e185 100644 --- a/mypy.ini +++ b/mypy.ini @@ -179,6 +179,10 @@ disable_error_code = attr-defined ignore_missing_imports = True [mypy-httpx.*] ignore_missing_imports = True +[mypy-jsonpath_ng.*] +ignore_missing_imports = True +[mypy-json5.*] +ignore_missing_imports = True [mypy-reboot.*] ignore_missing_imports = True [mypy-rbt.*] diff --git a/reboot/bdd/BUILD.bazel b/reboot/bdd/BUILD.bazel index 78578f0f8..96be3b114 100644 --- a/reboot/bdd/BUILD.bazel +++ b/reboot/bdd/BUILD.bazel @@ -37,6 +37,8 @@ py_library( visibility = ["//visibility:public"], deps = [ ":__init___py", + requirement("jsonpath-ng"), + requirement("pydantic"), requirement("pytest"), "//reboot:api_py", "//reboot/aio:aborted_py", @@ -55,6 +57,9 @@ py_library( ":__init___py", ":fixtures_py", ":registry_py", + requirement("json5"), + requirement("jsonpath-ng"), + requirement("pydantic"), requirement("pytest"), requirement("pytest-bdd"), "//reboot/aio:aborted_py", diff --git a/reboot/bdd/fixtures.py b/reboot/bdd/fixtures.py index 115533577..dd59cfd69 100644 --- a/reboot/bdd/fixtures.py +++ b/reboot/bdd/fixtures.py @@ -1,9 +1,13 @@ """The pytest fixtures that the built-in `reboot.bdd` steps run on.""" +import json +import jsonpath_ng import pytest from dataclasses import dataclass, field from google.protobuf import json_format from google.protobuf.message import Message +from jsonpath_ng.exceptions import JSONPathError +from pydantic import ValidationError from reboot.aio.aborted import Aborted from reboot.aio.external import ExternalContext from reboot.aio.tests import Reboot @@ -48,14 +52,58 @@ def _json_object( ) -> dict[str, 'JsonValue']: """The given message or model as its JSON object (a message via its canonical JSON, with fields without presence included, so - every property is reachable).""" + every property is reachable, and a model via its JSON-mode dump, + so e.g. a datetime is its string).""" if isinstance(message_or_model, Message): return json_format.MessageToDict( message_or_model, preserving_proto_field_name=True, always_print_fields_with_no_presence=True, ) - return message_or_model.model_dump() + return message_or_model.model_dump(mode='json') + + +@dataclass(frozen=True) +class PropertyPath: + """One property's path, in both its forms.""" + + # The path as the developer wrote it, e.g. 'owners["main"].name'; + # what error messages say. + text: str + + # The parsed path; what finds, updates, and grammar walks use. + expression: jsonpath_ng.JSONPath + + @staticmethod + def create(text: str) -> 'PropertyPath': + """The property path the given text parses as.""" + try: + expression = jsonpath_ng.parse(text) + except JSONPathError as error: + raise ValueError(f"Invalid property `{text}`: {error}") from error + return PropertyPath(text=text, expression=expression) + + +def _json_type(value: Any) -> type: + """The JSON type of a value: `int` and `float` are one number + type, and `bool` is its own.""" + if isinstance(value, bool): + return bool + if isinstance(value, (int, float)): + return float + return type(value) + + +def _zero_indexed(path: jsonpath_ng.JSONPath) -> jsonpath_ng.JSONPath: + """The path with every list index replaced by [0]; a path's list + element type is the same at every index.""" + match path: + case jsonpath_ng.Child(left=left, right=right): + return jsonpath_ng.Child(_zero_indexed(left), _zero_indexed(right)) + case jsonpath_ng.Index(): + return jsonpath_ng.Index(0) + case _: + return path @dataclass @@ -164,13 +212,172 @@ def is_reader(self, *, state_type: str, method: str) -> bool: reference = self.client_type(state_type).ref('is-reader') return hasattr(reference.reactively(), method) + def request_type( + self, + *, + state_type: str, + method: str, + ) -> Optional[type]: + """The request type of the named method, from the generated + client class's `Request` alias, or `None` when the + method takes no request.""" + client_type = self.client_type(state_type) + alias = method.replace('_', '').lower() + 'request' + for name in dir(client_type): + if name.lower() == alias: + request_type = getattr(client_type, name) + if isinstance(request_type, type): + return request_type + return None + + def request( + self, + *, + state_type: str, + method: str, + properties: Union[dict[str, JsonValue], list[tuple[PropertyPath, + JsonValue]]], + ) -> Any: + """The request the properties describe, validated by the named + method's request type. A dotted property name nests, e.g. + 'owner.name' describes the request's `owner` message's + `name`.""" + request_type = self.request_type(state_type=state_type, method=method) + if request_type is None: + raise ValueError( + f"`{state_type}`'s `{method}` takes no properties" + ) + + if isinstance(properties, dict): + properties = [ + (PropertyPath.create(text), value) + for text, value in properties.items() + ] + + # Build the JSON object a property at a time, where a property + # may add but never overwrite (a `find` hit is a collision). + result: dict[str, JsonValue] = {} + + def validate(path: PropertyPath) -> None: + """Raises for a path that names zero or many locations, + because building a request needs each property to name + exactly one place to update.""" + + def confirmed(expression: jsonpath_ng.JSONPath) -> None: + match expression: + case jsonpath_ng.Child(left=left, right=right): + confirmed(left) + confirmed(right) + case jsonpath_ng.Root(): + pass + case jsonpath_ng.Fields(fields=(_,)): + pass + case jsonpath_ng.Index(indices=(_,)): + pass + case _: + raise ValueError( + f"Property `{path.text}` may only say " + 'fields, ["key"]s, and [index]es, but ' + f"says: {expression}" + ) + + confirmed(path.expression) + + for path, value in properties: + # For creating a JSON object we disallow certain kinds of + # paths that just don't make sense or are not useful. + validate(path) + if path.expression.find(result): + raise ValueError( + f"Property `{path.text}` collides with another property" + ) + + def update( + # Object being updated. + child: Any, + # Containing object. + parent: Any, + # Field of containing object being updated. + field: Any, + ) -> Any: + if isinstance(parent, list): + # A list index past a list's current end, e.g., + # 'foo[5]' where the list only has 1 element, will + # pad the list with `{}` placeholders, even if the + # list is of strings or numbers, so we also + # confirm the value's JSON type matches the other + # elements': that refuses both a padded gap in a + # list of scalars and a mistyped element, while a + # gap in a list of objects stays, validated below + # as default-valued elements. The real backstop is + # doing the `model_validate` for Pydantic types + # and `ParseDict` for protobuf below, this is just + # extra. + for index, element in enumerate(parent): + if index == field: + continue + if _json_type(element) is not _json_type(value): + raise ValueError( + f"Property `{path.text}` indexes " + f"into a list whose element " + f"{element!r} is not the same type " + "as its value" + ) + # There is currently a bug in jsonpath-ng where + # returning a value does not always store it correctly + # so we need to store it ourselves and return it until + # h2non/jsonpath-ng#238 gets fixed. + parent[field] = value + return value + + try: + # NOTE: we are using the version of `update_or_create` + # that takes a callable because that forces + # jsonpath-ng to raise a KeyError if a path attempts + # to do a list index in an already existing dict + # (i.e., treating the dict like a list incorrectly). + path.expression.update_or_create(result, update) + except (KeyError, TypeError) as error: + raise ValueError( + f"Property `{path.text}` cannot be applied to " + "what is already built" + ) from error + # If the request is a Pydantic model, we use `model_validate`. + if hasattr(request_type, 'model_validate'): + # Guard against pydantic's default of ignoring unknown + # keys, which would make a mistyped property a silent + # no-op. + model_fields = getattr(request_type, 'model_fields') + for name in result: + if name not in model_fields: + raise ValueError( + f"`{request_type.__name__}` has no property " + f"`{name}`" + ) + try: + return request_type.model_validate(result) + except ValidationError as error: + raise ValueError( + f"Could not build a `{request_type.__name__}` " + f"from {json.dumps(result)}: {error}" + ) from error + # The request must be protobuf, use `ParseDict`. + try: + return json_format.ParseDict(result, request_type()) + except json_format.ParseError as error: + raise ValueError( + f"Could not build a `{request_type.__name__}` from " + f"{json.dumps(result)}: {error}" + ) from error + async def call( self, *, state_type: str, state_id: str, method: str, - properties: dict[str, Any], + properties: Union[dict[str, JsonValue], list[tuple[PropertyPath, + JsonValue]]], ) -> Any: """Calls the named method on the named state, with the properties as the request's, and returns its response.""" @@ -178,7 +385,16 @@ async def call( method_callable = getattr(reference, method, None) if not callable(method_callable): raise ValueError(f"`{state_type}` has no method `{method}`") - return await method_callable(self.context(), **properties) + if not properties: + return await method_callable(self.context()) + return await method_callable( + self.context(), + self.request( + state_type=state_type, + method=method, + properties=properties, + ), + ) @pytest.fixture diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index e3d0e8448..995462aea 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -16,9 +16,13 @@ def application() -> Application: Step text refers to a state type by its class name in backticks (or by its full state type name, e.g. `bank.v1.Account`, when more than one state type goes by the class name), to a state's ID in double -quotes, and to properties as a list of -`name=value` pairs, each in backticks, separated by commas or 'and', -whose values are Python literals: +quotes, and to properties as `path=value` assignments, each in +backticks, separated by commas or 'and'; the value is JSON, with +JSON5's leniencies (object keys need no quotes), and an object or +array value is validated by the method's request type when calling +and, when asserting, compared as the complete message the actual +value's type parses it as. A dotted path nests when calling, e.g. +`owner.name="Frank"`, and reaches into the response when asserting: Given the application is up And an `Account` for "alice" gets created via `open` @@ -48,44 +52,85 @@ def application() -> Application: # # ruff: noqa: F811 -import ast +import json5 +import jsonpath_ng import pytest import re +from google.protobuf import json_format +from google.protobuf.message import Message +# Re-exported so that `from reboot.bdd.steps import *` brings in the +# fixtures the steps run on. +from pydantic import TypeAdapter, ValidationError from pytest_bdd import parsers from reboot.aio.aborted import Aborted from reboot.aio.applications import Application from reboot.aio.tests import Reboot +from reboot.api import Model from reboot.bdd import given, then, when -# Re-exported so that `from reboot.bdd.steps import *` brings in the -# fixtures the steps run on. -from reboot.bdd.fixtures import JsonValue, World, _json_object +from reboot.bdd.fixtures import ( + JsonValue, + PropertyPath, + World, + _json_object, + _zero_indexed, +) from reboot.bdd.fixtures import rbt as rbt from reboot.bdd.fixtures import reboot_event_loop as reboot_event_loop from reboot.bdd.fixtures import world as world from reboot.bdd.registry import client_types_by_name -from typing import Any, Optional - -# One 'name=value' property in step text: the name (possibly dotted, -# to reach a nested property) and value in backticks, the value -# being anything up to the closing backtick. -_PROPERTY_PATTERN = re.compile(r'`(?P\w+(?:\.\w+)*)=(?P[^`]+)`') - -# One saving clause in a 'has' or 'where' list: the (possibly -# dotted) property name in backticks, saved under a '$name'. -_SAVE_PATTERN = re.compile( - r'`(?P\w+(?:\.\w+)*)` saved as "\$(?P\w+)"' -) +from typing import Any, Optional, Union, get_args, get_origin + +# A property path in step text: a leading field, then dotted fields, +# bracketed list indices, and bracketed map keys. +_PATH = r'\w+(?:\.\w+|\[\d+\]|\["[^"]*"\])*' + +# One 'path=value' property clause: the property's path and value +# in backticks, the value being anything up to the closing backtick. +# The groupless form embeds in step patterns and deliberately also +# matches lexical near-misses (':' for '=', spaces around the '=', +# an empty value) so that those route to a step whose parser +# raises the fix; the compiled form is the strict shape, for +# extraction. +_PROPERTY_CLAUSE = rf'`{_PATH}\s*[:=]\s*[^`]*`' +_PROPERTY_PATTERN = re.compile(rf'`(?P{_PATH})=(?P\S[^`]*)`') + +# One saving clause: the (possibly dotted) property name in +# backticks, saved under a '$name'. The groupless form embeds in +# step patterns and deliberately also matches lexical near-misses +# ('saved to', a missing '$' or missing quotes) so that those route +# to a step whose parser raises the fix; the compiled form is the +# strict shape, for extraction. +_SAVE_CLAUSE = rf'`{_PATH}`\s+saved\s+(?:as|to)\s+"?\$?\w+"?' +_SAVE_PATTERN = re.compile(rf'`(?P{_PATH})` saved as "\$(?P\w+)"') # What separates two clauses in step text: a comma, an 'and', or a # comma followed by an 'and'. -_SEPARATOR_PATTERN = re.compile(r'\s*(?:,\s*and|,|and)\s+') +_SEPARATOR = r'\s*(?:,\s*and|,|and)\s+' + +# A clause list of only 'path=value' properties: what a 'with' +# passes to a call, and what a Then 'has'/'where' asserts. +_PROPERTY_CLAUSES = rf'{_PROPERTY_CLAUSE}(?:{_SEPARATOR}{_PROPERTY_CLAUSE})*' + +# A clause list of only saving clauses: what a Given or When 'has' +# saves. +_SAVE_CLAUSES = rf'{_SAVE_CLAUSE}(?:{_SEPARATOR}{_SAVE_CLAUSE})*' + +# A clause list mixing both kinds, which no step accepts; it exists +# so the mistake gets a pointed error instead of an unmatched step. +# A property value can never contain a backtick, so the lookaheads +# can only hit an actual clause of each kind. +_CLAUSE = rf'(?:{_PROPERTY_CLAUSE}|{_SAVE_CLAUSE})' +_MIXED_CLAUSES = ( + rf'(?=.*`\s+saved\s)(?=.*`{_PATH}\s*[:=])' + rf'{_CLAUSE}(?:{_SEPARATOR}{_CLAUSE})*' +) # The 'the `Account` for "alice"' phrase naming the state a step acts # on. _STATE = r'the `(?P[\w.]+)` for "(?P[^"]*)"' # A step's optional trailing property list. -_PROPERTIES = r'(?: with (?P.+))?' +_PROPERTIES = rf'(?: with (?P{_PROPERTY_CLAUSES}))?' def _saved_value(world: World, name: str) -> JsonValue: @@ -113,167 +158,281 @@ def _resolve_state_id(world: World, state_id: str) -> str: return value -def _parse_clauses( - world: World, - clauses: Optional[str], -) -> tuple[dict[str, Any], dict[str, str]]: - """Parses a step's clause list into the properties to compare, - e.g. '`amount=50`', and, keyed by the name to save under, the - properties to save, e.g. '`amount` saved as "$amount"'; a - property value of the form '$name' becomes the saved value going - by that name.""" - properties: dict[str, Any] = {} - saves: dict[str, str] = {} - if clauses is None: - return properties, saves - text = clauses.strip() - position = 0 - while position < len(text): - if position > 0: - separator = _SEPARATOR_PATTERN.match(text, position) - if separator is None: - raise ValueError( - "Expected a ',' or 'and' between clauses, but " - f"got: {text[position:]}" - ) - position = separator.end() - save_match = _SAVE_PATTERN.match(text, position) - if save_match is not None: - saves[save_match['saved']] = save_match['name'] - position = save_match.end() - continue - property_match = _PROPERTY_PATTERN.match(text, position) - if property_match is None: - raise ValueError( - "Expected a clause of the form `name=value` or " - "`name` saved as \"$name\", but got: " - f"{text[position:]}" - ) - try: - if re.fullmatch(r'\$\w+', property_match['value']): - value = _saved_value(world, property_match['value'][1:]) - else: - value = ast.literal_eval(property_match['value']) - except (ValueError, SyntaxError) as error: - raise ValueError( - f"The value of `{property_match['name']}` must be a Python " - "literal, e.g. 50, 2.5, \"text\", or True, but got: " - f"{property_match['value']}" - ) from error - properties[property_match['name']] = value - position = property_match.end() - return properties, saves +def _almost_property_message(clause: str) -> str: + """The 'Almost' error for a property clause that is a lexical + near-miss of `path=value`.""" + if re.match(rf'`{_PATH}\s*:', clause): + return f"Almost: say `path=value` with '=', not ':': {clause}" + if re.fullmatch(rf'`{_PATH}\s*=\s*`', clause): + return f"Almost: the value is missing: {clause}" + if re.match(rf'`{_PATH}\s+=', clause) or re.match(rf'`{_PATH}=\s', clause): + return ( + "Almost: write `path=value` without spaces around the " + f"'=': {clause}" + ) + return f"Expected a property of the form `path=value`, but got: {clause}" + + +def _almost_save_message(clause: str) -> str: + """The 'Almost' error for a saving clause that is a lexical + near-miss of `name` saved as "$name".""" + if re.search(r'\bsaved\s+to\b', clause): + return f"Almost: say 'saved as', not 'saved to': {clause}" + if re.search(r'\bsaved\s+as\s+\$\w+$', clause): + return f'Almost: quote the name, e.g. saved as "$name": {clause}' + if re.search(r'\bsaved\s+as\s+"\w+"$', clause): + return ( + "Almost: the name needs a '$', e.g. saved as " + f'"$name": {clause}' + ) + return ( + 'Expected a saving clause of the form `name` saved as "$name", ' + f'but got: {clause}' + ) def _parse_properties( world: World, - properties: Optional[str], -) -> dict[str, Any]: + clauses: Optional[str], +) -> list[tuple[PropertyPath, JsonValue]]: """Parses a step's property list, e.g. '`amount=50` and - `reason="promo"`', into a dictionary of Python literal values; a - saving clause is refused, it belongs in a 'has' or 'where' - list.""" - parsed, saves = _parse_clauses(world, properties) - if saves: - raise ValueError( - "A property can only be saved from a 'has' or 'where' " - "list, not passed to a call: " + - ', '.join(f'"${name}"' for name in sorted(saves)) - ) + `reason="promo"`', into a dictionary of JSON values; a property + value of the form '$name' becomes the saved value going by that + name. The step patterns admit lexical near-misses of a clause, + so each clause is confirmed strict here, raising the fix.""" + parsed: list[tuple[PropertyPath, JsonValue]] = [] + if clauses is None: + return parsed + for clause_match in re.finditer(_PROPERTY_CLAUSE, clauses): + property_match = _PROPERTY_PATTERN.fullmatch(clause_match[0]) + if property_match is None: + raise ValueError(_almost_property_message(clause_match[0])) + if re.fullmatch(r'\$\w+', property_match['value']): + value = _saved_value(world, property_match['value'][1:]) + else: + try: + value = json5.loads(property_match['value']) + except ValueError as error: + raise ValueError( + f"The value of `{property_match['path']}` must " + "be JSON, e.g. 50, 2.5, \"text\", true, or " + '{name: "value"}, but got: ' + f"{property_match['value']}" + ) from error + parsed.append((PropertyPath.create(property_match['path']), value)) return parsed -def _assert_clauses( - world: World, - subject: Any, - clauses: Optional[str], -) -> None: - """Asserts the given clause list against the given response, - state, or error; saving clauses are refused, they belong under - Given or When.""" - properties, saves = _parse_clauses(world, clauses) - if saves: - raise ValueError( - "A Then 'has' or 'where' asserts; save under Given or " - "When instead: " + - ', '.join(f'"${name}"' for name in sorted(saves)) +def _parse_saves(clauses: str) -> dict[str, PropertyPath]: + """Parses a Given or When 'has' list of saving clauses, e.g. + '`amount` saved as "$amount"', into the property to save under + each name. The step patterns admit lexical near-misses of a + clause, so each clause is confirmed strict here, raising the + fix.""" + saves: dict[str, PropertyPath] = {} + for clause_match in re.finditer(_SAVE_CLAUSE, clauses): + save_match = _SAVE_PATTERN.fullmatch(clause_match[0]) + if save_match is None: + raise ValueError(_almost_save_message(clause_match[0])) + saves[save_match['saved']] = PropertyPath.create(save_match['path']) + return saves + + +def _resolve_json_property( + json_object: JsonValue, + path: PropertyPath, +) -> JsonValue: + """The value the property's path finds in the given JSON object; + walking the response's JSON, rather than the live response, keeps + every value canonical JSON.""" + found = path.expression.find(json_object) + if len(found) == 1: + return found[0].value + if len(found) > 1: + raise AssertionError( + f"Expected `{path.text}` to find one value, but it found " + f"{len(found)}" ) - _assert_properties(subject, properties) - -def _save_clauses( - world: World, - subject: Any, - clauses: Optional[str], -) -> None: - """Saves the properties the given clause list names from the - given response; comparing clauses are refused, they belong in a - Then.""" - properties, saves = _parse_clauses(world, clauses) - if properties: - raise ValueError( - "A Given or When 'has' saves; assert with a Then " - "instead: " + - ', '.join(f'`{name}`' for name in sorted(properties)) - ) - if not saves: - raise ValueError( - "Expected at least one saving clause, e.g. " - '`name` saved as "$name"' - ) - subject_json = _json_object(subject) - for name, property_name in saves.items(): - world.saved[name] = _resolve_json_property(subject_json, property_name) - - -def _resolve_json_property(json_object: JsonValue, name: str) -> JsonValue: - """The value the (possibly dotted) property name reaches in the - given JSON object; saving walks the response's JSON, rather than - the live response the way asserting does, so that saved values - are canonical JSON.""" - value = json_object - for part in name.split('.'): - if not isinstance(value, dict): - raise AssertionError( - f"Expected an object with a property `{part}` (from " - f"`{name}`), but got: {value!r}" - ) - if part not in value: - raise AssertionError( - f"Expected a property `{part}` (from `{name}`), but " - "there are: " + ( - ', '.join(f'`{n}`' - for n in sorted(value)) or "no properties" + # Nothing found: probe the path prefix by prefix for an error + # naming where and why. + def atoms( + expression: jsonpath_ng.JSONPath, + ) -> list[jsonpath_ng.JSONPath]: + match expression: + case jsonpath_ng.Child(left=left, right=right): + return atoms(left) + atoms(right) + case jsonpath_ng.Root(): + return [] + case _: + return [expression] + + prefix: Optional[jsonpath_ng.JSONPath] = None + value: JsonValue = json_object + for atom in atoms(path.expression): + prefix = atom if prefix is None else jsonpath_ng.Child(prefix, atom) + prefixed = prefix.find(json_object) + if prefixed: + value = prefixed[0].value + continue + match atom: + case jsonpath_ng.Index(indices=(index,) + ) if isinstance(value, list): + raise AssertionError( + f"Expected at least {index + 1} elements at " + f"`{prefix}` (from `{path.text}`), but there " + f"are {len(value)}" ) - ) - value = value[part] - return value - - -def _resolve_property(subject: Any, name: str) -> Any: - """The value the (possibly dotted) property name reaches on the - given response, state, or error.""" - actual = subject - for attribute in name.split('.'): + case jsonpath_ng.Index(): + raise AssertionError( + f"Expected a list at `{prefix}` (from " + f"`{path.text}`), but got: {value!r}" + ) + case jsonpath_ng.Fields(fields=(fieldname,) + ) if isinstance(value, dict): + raise AssertionError( + f"Expected a property `{fieldname}` (from " + f"`{path.text}`), but there are: " + ( + ', '.join(f'`{n}`' + for n in sorted(value)) or "no properties" + ) + ) + case _: + raise AssertionError( + f"Expected an object at `{prefix}` (from " + f"`{path.text}`), but got: {value!r}" + ) + raise AssertionError(f"Expected `{path.text}` to find one value") + + +def _proto_property_matches( + message_type: type[Message], + path: PropertyPath, + actual: JsonValue, + expected: JsonValue, +) -> bool: + """Whether the actual (canonical JSON) value of the named + property equals the expected JSON value under the message type's + semantics: both are parsed into the type as just that property + and the resulting messages compared, so e.g. a 64-bit integer + matches its canonical string form and an object compares as the + complete message with unset properties at their defaults.""" + # We transform the path to always set the first (0th index) of a + # list vs what ever the path originally was extracting (e.g., [2] + # for the 3rd element) so that we aren't comparing lists with gaps + # (which won't always work and doesn't buy us anything anyway). + expression = _zero_indexed(path.expression) + + def sparse(value: JsonValue) -> Message: + result: dict[str, JsonValue] = {} + expression.update_or_create(result, value) try: - actual = getattr(actual, attribute) - except AttributeError as error: + return json_format.ParseDict(result, message_type()) + except json_format.ParseError as error: raise AssertionError( - f"Expected `{type(actual).__name__}` to have a " - f"property `{attribute}` (from `{name}`), but it has " - "no such property" + f"`{path.text}` cannot be {value!r} on " + f"`{message_type.__name__}`: {error}" ) from error - return actual + # Create a sparse message that only has the values set from what + # `path` dictates, such that we can then just rely on protobuf + # comparisons to handle things like 64-bit integers (which are + # strings in JSON) or bytes (which are base64 encoded). + return sparse(actual) == sparse(expected) + + +def _without_optional(annotation: Any) -> Any: + """The annotation with an `Optional[...]` wrapper removed.""" + if get_origin(annotation) is Union: + arguments = [ + argument for argument in get_args(annotation) + if argument is not type(None) + ] + if len(arguments) == 1: + return arguments[0] + return annotation + + +def _pydantic_annotation(model_type: type[Model], path: PropertyPath) -> Any: + """The annotation the property's path reaches on the given model + type: a field reaches a model's field or a `dict` value, and an + index a `list` element.""" + + def reached(annotation: Any, expression: jsonpath_ng.JSONPath) -> Any: + match expression: + case jsonpath_ng.Child(left=left, right=right): + return reached(reached(annotation, left), right) + case jsonpath_ng.Root(): + return annotation + annotation = _without_optional(annotation) + match expression: + case jsonpath_ng.Fields(fields=(fieldname,)) if ( + isinstance(annotation, type) and issubclass(annotation, Model) + ): + field = annotation.model_fields.get(str(fieldname)) + if field is None: + raise AssertionError( + f"`{annotation.__name__}` has no property " + f"`{fieldname}` (from `{path.text}`)" + ) + return field.annotation + case jsonpath_ng.Fields() if get_origin(annotation) is dict: + return get_args(annotation)[1] + case jsonpath_ng.Index() if get_origin(annotation) is list: + return get_args(annotation)[0] + case _: + raise AssertionError( + f"Cannot reach `{expression}` (from `{path.text}`) " + f"in {annotation!r}" + ) + + return reached(model_type, path.expression) + + +def _pydantic_property_matches( + model_type: type[Model], + path: PropertyPath, + actual: JsonValue, + expected: JsonValue, +) -> bool: + """Whether the actual (dumped) value of the named property equals + the expected JSON value under the model type's semantics: both + sides validate as the property's annotation, so an object + compares as the complete model with missing properties at their + defaults, and a value in its JSON spelling equals the value it + validates as.""" + adapter = TypeAdapter(_pydantic_annotation(model_type, path)) + try: + return adapter.validate_python(actual + ) == adapter.validate_python(expected) + except ValidationError as error: + raise AssertionError( + f"`{path.text}` cannot be {expected!r} on " + f"`{model_type.__name__}`: {error}" + ) from error -def _assert_properties(subject: Any, properties: dict[str, Any]) -> None: - """Asserts that each of the given (possibly dotted) property names - reaches the expected value on the given response, state, or - error.""" - for name, expected in properties.items(): - actual = _resolve_property(subject, name) - assert actual == expected, ( - f"Expected `{name}` to be {expected!r}, but it is {actual!r}" + +def _assert_properties( + subject: Union[Message, Model], + properties: list[tuple[PropertyPath, JsonValue]], +) -> None: + """Asserts that each of the given property paths reaches the + expected value on the given response or error, comparing under + the subject type's semantics.""" + subject_json = _json_object(subject) + for path, expected in properties: + actual = _resolve_json_property(subject_json, path) + if isinstance(subject, Message): + matches = _proto_property_matches( + type(subject), path, actual, expected + ) + else: + matches = _pydantic_property_matches( + type(subject), path, actual, expected + ) + assert matches, ( + f"Expected `{path.text}` to be {expected!r}, but it is " + f"{actual!r}" ) @@ -301,19 +460,30 @@ def _a_shared_context(world: World) -> None: rf'gets created via `(?P\w+)`{_PROPERTIES}$' ) ) +@when( + parsers.re( + r'(?:a|an) `(?P[\w.]+)` for "(?P[^"]*)" ' + rf'gets created via `(?P\w+)`{_PROPERTIES}$' + ) +) async def _gets_created_via( world: World, state_type: str, state_id: str, method: str, - properties: Optional[str], + clauses: Optional[str], ) -> None: factory = world.factory(state_type=state_type, method=method) - try: - _, world.response = await factory( - world.context(), _resolve_state_id(world, state_id), - **_parse_properties(world, properties) + properties = _parse_properties(world, clauses) + arguments = [world.context(), _resolve_state_id(world, state_id)] + if properties: + arguments.append( + world.request( + state_type=state_type, method=method, properties=properties + ) ) + try: + _, world.response = await factory(*arguments) except Aborted as aborted: raise AssertionError( f"Creating the `{state_type}` for \"{state_id}\" via " @@ -328,7 +498,7 @@ async def _gets_a( state_type: str, state_id: str, method: str, - properties: Optional[str], + clauses: Optional[str], ) -> None: if world.is_reader(state_type=state_type, method=method): raise ValueError( @@ -340,7 +510,7 @@ async def _gets_a( state_type=state_type, state_id=_resolve_state_id(world, state_id), method=method, - properties=_parse_properties(world, properties), + properties=_parse_properties(world, clauses), ) except Aborted as aborted: raise AssertionError( @@ -357,7 +527,7 @@ async def _attempts_a( state_type: str, state_id: str, method: str, - properties: Optional[str], + clauses: Optional[str], ) -> None: if world.is_reader(state_type=state_type, method=method): raise ValueError( @@ -370,7 +540,7 @@ async def _attempts_a( state_type=state_type, state_id=_resolve_state_id(world, state_id), method=method, - properties=_parse_properties(world, properties), + properties=_parse_properties(world, clauses), ) world.aborted = None except Aborted as aborted: @@ -390,13 +560,13 @@ def _assert_aborted( f"Expected an abort with `{error_type}`, but it aborted " f"with `{type(error).__name__}`: {aborted}" ) - _assert_clauses(world, error, clauses) + _assert_properties(error, _parse_properties(world, clauses)) @then( parsers.re( r'the attempt aborts with `(?P\w+)`' - r'(?: where (?P.+))?$' + rf'(?: where (?P{_PROPERTY_CLAUSES}))?$' ) ) def _the_attempt_aborts_with( @@ -439,8 +609,12 @@ async def _read( ) from aborted -@then(parsers.re(rf'`(?P\w+)` on {_STATE} ' - r'has (?P.+)$')) +@then( + parsers.re( + rf'`(?P\w+)` on {_STATE} ' + rf'has (?P{_PROPERTY_CLAUSES})$' + ) +) async def _then_has( world: World, method: str, @@ -449,14 +623,22 @@ async def _then_has( clauses: str, ) -> None: response = await _read(world, method, state_type, state_id) - _assert_clauses(world, response, clauses) + _assert_properties(response, _parse_properties(world, clauses)) -@given(parsers.re(rf'`(?P\w+)` on {_STATE} ' - r'has (?P.+)$')) -@when(parsers.re(rf'`(?P\w+)` on {_STATE} ' - r'has (?P.+)$')) -async def _when_has( +@given( + parsers.re( + rf'`(?P\w+)` on {_STATE} ' + rf'has (?P{_SAVE_CLAUSES})$' + ) +) +@when( + parsers.re( + rf'`(?P\w+)` on {_STATE} ' + rf'has (?P{_SAVE_CLAUSES})$' + ) +) +async def _has_saved_as( world: World, method: str, state_type: str, @@ -464,14 +646,16 @@ async def _when_has( clauses: str, ) -> None: response = await _read(world, method, state_type, state_id) - _save_clauses(world, response, clauses) + response_json = _json_object(response) + for name, path in _parse_saves(clauses).items(): + world.saved[name] = _resolve_json_property(response_json, path) @then( parsers.re( rf'`(?P\w+)` on {_STATE} ' r'aborts with `(?P\w+)`' - r'(?: where (?P.+))?$' + rf'(?: where (?P{_PROPERTY_CLAUSES}))?$' ) ) async def _aborts_with( @@ -505,24 +689,24 @@ async def _aborts_with( ) -@then(parsers.re(r'the result has (?P.+)$')) +@then(parsers.re(rf'the result has (?P{_PROPERTY_CLAUSES})$')) def _the_result_has(world: World, clauses: str) -> None: assert world.response is not None, ( "Expected a preceding step to have made a call that returned " "a response, but there is none" ) - _assert_clauses(world, world.response, clauses) + _assert_properties(world.response, _parse_properties(world, clauses)) @given( parsers.re( - r'the resulting `(?P\w+(?:\.\w+)*)` ' + rf'the resulting `(?P{_PATH})` ' r'is saved as "\$(?P\w+)"$' ) ) @when( parsers.re( - r'the resulting `(?P\w+(?:\.\w+)*)` ' + rf'the resulting `(?P{_PATH})` ' r'is saved as "\$(?P\w+)"$' ) ) @@ -536,5 +720,98 @@ def _the_resulting_property_is_saved_as( "a response, but there is none" ) world.saved[name] = _resolve_json_property( - _json_object(world.response), property_name + _json_object(world.response), PropertyPath.create(property_name) + ) + + +# The steps below match only *invalid* clause lists, each a +# near-miss of the grammar the steps above declare, so that the +# mistake raises a pointed error instead of pytest-bdd's unmatched +# step. Each pattern is disjoint from every real step's: a real +# step's tail never matches one of these. + + +@given(parsers.re(rf'.+ has {_PROPERTY_CLAUSES}$')) +@when(parsers.re(rf'.+ has {_PROPERTY_CLAUSES}$')) +def _almost_asserting_under_given_or_when() -> None: + raise ValueError( + "Almost: a Given or When 'has' saves, e.g. `name` saved as " + "\"$name\"; assert `path=value` properties with a Then " + "instead" ) + + +@then(parsers.re(rf'.+ has {_SAVE_CLAUSES}$')) +def _almost_saving_under_then() -> None: + raise ValueError( + "Almost: a Then 'has' asserts `path=value` properties; " + "save under a Given or When 'has' instead" + ) + + +@given(parsers.re(rf'.+ has {_MIXED_CLAUSES}$')) +@when(parsers.re(rf'.+ has {_MIXED_CLAUSES}$')) +@then(parsers.re(rf'.+ has {_MIXED_CLAUSES}$')) +def _almost_mixing_clauses() -> None: + raise ValueError( + "Almost: a 'has' list is all one kind; a Given or When " + "'has' saves, and a Then 'has' asserts `path=value` " + "properties" + ) + + +@given( + parsers.re( + rf'.+ with (?=.*`\s+saved\s){_CLAUSE}' + rf'(?:{_SEPARATOR}{_CLAUSE})*$' + ) +) +@when( + parsers.re( + rf'.+ with (?=.*`\s+saved\s){_CLAUSE}' + rf'(?:{_SEPARATOR}{_CLAUSE})*$' + ) +) +def _almost_saving_in_with() -> None: + raise ValueError( + "Almost: saving goes under a Given or When 'has', not a " + "'with' list" + ) + + +@then( + parsers.re( + rf'.+ where (?=.*`\s+saved\s){_CLAUSE}' + rf'(?:{_SEPARATOR}{_CLAUSE})*$' + ) +) +def _almost_saving_in_where() -> None: + raise ValueError( + "Almost: saving goes under a Given or When 'has', not a " + "'where' list" + ) + + +# A clause list with no backticks at all, and one whose backticks do +# not pair up (a leading backtick followed by zero or more closed +# pairs leaves one unclosed): every valid clause list pairs its +# backticks, so both shapes are disjoint from every step above. +_UNBACKTICKED_CLAUSES = r'[^`]+' +_UNCLOSED_CLAUSES = r'`[^`]*(?:`[^`]*`[^`]*)*' + + +@given(parsers.re(rf'.+ (?:with|has|where) {_UNBACKTICKED_CLAUSES}$')) +@when(parsers.re(rf'.+ (?:with|has|where) {_UNBACKTICKED_CLAUSES}$')) +@then(parsers.re(rf'.+ (?:with|has|where) {_UNBACKTICKED_CLAUSES}$')) +def _almost_missing_backticks() -> None: + raise ValueError( + "Almost: each clause goes in backticks, e.g. `amount=50` " + 'or `amount` saved as "$amount"' + ) + + +@given(parsers.re(rf'.+ (?:with|has|where) {_UNCLOSED_CLAUSES}$')) +@when(parsers.re(rf'.+ (?:with|has|where) {_UNCLOSED_CLAUSES}$')) +@then(parsers.re(rf'.+ (?:with|has|where) {_UNCLOSED_CLAUSES}$')) +def _almost_unclosed_backtick() -> None: + raise ValueError("Almost: a backtick is unclosed") diff --git a/reboot/requirements.in b/reboot/requirements.in index efae38261..e846a38d8 100644 --- a/reboot/requirements.in +++ b/reboot/requirements.in @@ -23,6 +23,8 @@ pathspec==0.12.1 # Latest as of 2024/04/22. protobuf==5.28.3 # Aligned with `grpcio`. psutil==6.0.0 # Latest as of 2024/09/10. pyjwt==2.10.1 # Latest as of 2024/11/27. +jsonpath-ng==1.8.0 # For `reboot.bdd`; latest as of 2026/09/03. +json5==0.15.0 # For `reboot.bdd`; latest as of 2026/09/03. pytest==8.4.2 # For `reboot.bdd`; latest 8.x as of 2026/09/01. pytest-bdd==8.1.0 # For `reboot.bdd`; latest as of 2026/09/01. python-dotenv==1.2.1 # Used by `rbt dev run --env-file`. diff --git a/reboot/requirements_lock.txt b/reboot/requirements_lock.txt index 0c12cde15..657e89593 100644 --- a/reboot/requirements_lock.txt +++ b/reboot/requirements_lock.txt @@ -667,6 +667,14 @@ jinja2-strcase==0.0.2 \ --hash=sha256:d90c37f7bd40d345aacc8f78b087f66e6c5aa4c968ab23a791573fcf756c3379 \ --hash=sha256:e3067062f6158cd836ab495f805fc0d1d83cf92049e180509b004db86f6b745c # via -r reboot/requirements.in +json5==0.15.0 \ + --hash=sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618 \ + --hash=sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71 + # via -r reboot/requirements.in +jsonpath-ng==1.8.0 \ + --hash=sha256:54252968134b5e549ea5b872f1df1168bd7defe1a52fed5a358c194e1943ddc3 \ + --hash=sha256:b8dde192f8af58d646fc031fac9c99fe4d00326afc4148f1f043c601a8cfe138 + # via -r reboot/requirements.in jsonschema==4.26.0 \ --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce diff --git a/tests/reboot/bdd/account.proto b/tests/reboot/bdd/account.proto index 735c0d871..4458e1998 100644 --- a/tests/reboot/bdd/account.proto +++ b/tests/reboot/bdd/account.proto @@ -9,6 +9,14 @@ message Account { option (rbt.v1alpha1.state) = { }; int64 balance = 1; + Owner owner = 2; + map owners = 3; +} + +// The person an account belongs to. +message Owner { + string name = 1; + repeated string tags = 2; } service AccountMethods { @@ -23,6 +31,26 @@ service AccountMethods { }; } + rpc SetOwner(SetOwnerRequest) returns (SetOwnerResponse) { + option (rbt.v1alpha1.method).writer = { + }; + } + + rpc GetOwner(GetOwnerRequest) returns (GetOwnerResponse) { + option (rbt.v1alpha1.method).reader = { + }; + } + + rpc PutOwner(PutOwnerRequest) returns (PutOwnerResponse) { + option (rbt.v1alpha1.method).writer = { + }; + } + + rpc GetOwners(GetOwnersRequest) returns (GetOwnersResponse) { + option (rbt.v1alpha1.method).reader = { + }; + } + rpc Deposit(DepositRequest) returns (DepositResponse) { option (rbt.v1alpha1.method).writer = { }; @@ -47,6 +75,34 @@ message OpenResponse { message BalanceRequest {} +message SetOwnerRequest { + Owner owner = 1; + + // Owners in addition to `owner`. + repeated Owner co_owners = 2; +} + +message SetOwnerResponse {} + +message GetOwnerRequest {} + +message GetOwnerResponse { + Owner owner = 1; +} + +message PutOwnerRequest { + string key = 1; + Owner owner = 2; +} + +message PutOwnerResponse {} + +message GetOwnersRequest {} + +message GetOwnersResponse { + map owners = 1; +} + message BalanceResponse { int64 balance = 1; } diff --git a/tests/reboot/bdd/account_servicer.py b/tests/reboot/bdd/account_servicer.py index b2b8d5792..af60445da 100644 --- a/tests/reboot/bdd/account_servicer.py +++ b/tests/reboot/bdd/account_servicer.py @@ -9,8 +9,16 @@ BalanceResponse, DepositRequest, DepositResponse, + GetOwnerRequest, + GetOwnerResponse, + GetOwnersRequest, + GetOwnersResponse, OpenRequest, OpenResponse, + PutOwnerRequest, + PutOwnerResponse, + SetOwnerRequest, + SetOwnerResponse, WithdrawRequest, WithdrawResponse, ) @@ -36,6 +44,36 @@ async def balance( ) -> BalanceResponse: return BalanceResponse(balance=self.state.balance) + async def set_owner( + self, + context: WriterContext, + request: SetOwnerRequest, + ) -> SetOwnerResponse: + self.state.owner.CopyFrom(request.owner) + return SetOwnerResponse() + + async def get_owner( + self, + context: ReaderContext, + request: GetOwnerRequest, + ) -> GetOwnerResponse: + return GetOwnerResponse(owner=self.state.owner) + + async def put_owner( + self, + context: WriterContext, + request: PutOwnerRequest, + ) -> PutOwnerResponse: + self.state.owners[request.key].CopyFrom(request.owner) + return PutOwnerResponse() + + async def get_owners( + self, + context: ReaderContext, + request: GetOwnersRequest, + ) -> GetOwnersResponse: + return GetOwnersResponse(owners=self.state.owners) + async def deposit( self, context: WriterContext, diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 83f0ea84d..566fcc848 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -37,9 +37,31 @@ Feature: Accounts When the `Account` for "gus" gets a `deposit` with `amount=$initial` Then `balance` on the `Account` for "gus" has `balance=14` + Scenario: Properties can be messages + Given an `Account` for "frank" gets created via `open` + When the `Account` for "frank" gets a `set_owner` with `owner={name: "Frank", tags: ["vip", "beta"]}` + Then `get_owner` on the `Account` for "frank" has `owner={name: "Frank", tags: ["vip", "beta"]}` + When the `Account` for "frank" gets a `set_owner` with `owner.name="Frankie"` and `owner.tags=["pro"]` + Then `get_owner` on the `Account` for "frank" has `owner={name: "Frankie", tags: ["pro"]}` + And `get_owner` on the `Account` for "frank" has `owner.name="Frankie"` + And `get_owner` on the `Account` for "frank" has `owner.tags[0]="pro"` + When `get_owner` on the `Account` for "frank" has `owner.name` saved as "$owner_name" + And an `Account` for "$owner_name" gets created via `open` with `initial_balance=1` + Then `balance` on the `Account` for "Frankie" has `balance=1` + When `get_owner` on the `Account` for "frank" has `owner` saved as "$owner" + And an `Account` for "franklin" gets created via `open` + And the `Account` for "franklin" gets a `set_owner` with `owner=$owner` + Then `get_owner` on the `Account` for "franklin" has `owner={name: "Frankie", tags: ["pro"]}` + Scenario: Readers can abort Then `balance` on the `Account` for "ghost" aborts with `StateNotConstructed` + Scenario: Properties reach through maps + Given an `Account` for "heidi" gets created via `open` + When the `Account` for "heidi" gets a `put_owner` with `key="main"` and `owner={name: "Heidi", tags: ["a"]}` + Then `get_owners` on the `Account` for "heidi" has `owners["main"].name="Heidi"` + And `get_owners` on the `Account` for "heidi" has `owners={main: {name: "Heidi", tags: ["a"]}}` + Scenario: Steps can share one context Given a shared context And an `Account` for "dave" gets created via `open` diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index 9b396b9c2..05873b52f 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -8,12 +8,34 @@ # ruff: noqa: F403 import pytest +import re from pytest_bdd import parsers, scenarios from reboot.bdd import when -from reboot.bdd.fixtures import World +from reboot.bdd.fixtures import JsonValue, PropertyPath, World from reboot.bdd.steps import * -from reboot.bdd.steps import _assert_aborted -from tests.reboot.bdd.account_pb2 import OverdraftError +from reboot.bdd.steps import ( + _MIXED_CLAUSES, + _PROPERTY_CLAUSES, + _SAVE_CLAUSES, + _almost_asserting_under_given_or_when, + _almost_missing_backticks, + _almost_mixing_clauses, + _almost_saving_in_where, + _almost_saving_in_with, + _almost_saving_under_then, + _almost_unclosed_backtick, + _assert_aborted, + _assert_properties, + _parse_properties, + _parse_saves, +) +from tests.reboot.bdd.account_pb2 import ( + BalanceResponse, + GetOwnerResponse, + OpenResponse, + OverdraftError, + Owner, +) from tests.reboot.bdd.account_rbt import Account @@ -38,6 +60,66 @@ def test_is_reader() -> None: assert not world.is_reader(state_type='Account', method='deposit') +def test_clause_grammar_routing() -> None: + properties = '`balance=50` and `owner.name="F"`' + saves = '`balance` saved as "$b", and `owner` saved as "$o"' + mixed = '`balance=50` and `owner` saved as "$o"' + assert re.fullmatch(_PROPERTY_CLAUSES, properties) + assert not re.fullmatch(_PROPERTY_CLAUSES, saves) + assert not re.fullmatch(_PROPERTY_CLAUSES, mixed) + assert re.fullmatch(_SAVE_CLAUSES, saves) + assert not re.fullmatch(_SAVE_CLAUSES, properties) + assert not re.fullmatch(_SAVE_CLAUSES, mixed) + assert re.fullmatch(_MIXED_CLAUSES, mixed) + assert not re.fullmatch(_MIXED_CLAUSES, properties) + assert not re.fullmatch(_MIXED_CLAUSES, saves) + # Lexical near-misses still route to their kind. + assert re.fullmatch(_PROPERTY_CLAUSES, '`amount: 50`') + assert re.fullmatch(_PROPERTY_CLAUSES, '`amount = 50`') + assert re.fullmatch(_SAVE_CLAUSES, '`balance` saved to "$b"') + assert re.fullmatch(_SAVE_CLAUSES, '`balance` saved as $b') + + +def test_almost_clause_messages() -> None: + world = World() + with pytest.raises(ValueError, match="with '=', not ':'"): + _parse_properties(world, '`amount: 50`') + with pytest.raises(ValueError, match="without spaces around the '='"): + _parse_properties(world, '`amount = 50`') + with pytest.raises(ValueError, match="without spaces around the '='"): + _parse_properties(world, '`amount= 50`') + with pytest.raises(ValueError, match="the value is missing"): + _parse_properties(world, '`amount=`') + with pytest.raises(ValueError, match="must be JSON"): + _parse_properties(world, '`amount=abc`') + assert _parse_properties(world, '`owner={name: "F"}`')[0][1] == { + 'name': 'F' + } + with pytest.raises(ValueError, match="'saved as', not 'saved to'"): + _parse_saves('`balance` saved to "$b"') + with pytest.raises(ValueError, match="quote the name"): + _parse_saves('`balance` saved as $b') + with pytest.raises(ValueError, match=r"the name needs a '\$'"): + _parse_saves('`balance` saved as "b"') + + +def test_almost_steps_raise() -> None: + with pytest.raises(ValueError, match="with a Then instead"): + _almost_asserting_under_given_or_when() + with pytest.raises(ValueError, match="Given or When 'has' instead"): + _almost_saving_under_then() + with pytest.raises(ValueError, match="all one kind"): + _almost_mixing_clauses() + with pytest.raises(ValueError, match="not a 'with' list"): + _almost_saving_in_with() + with pytest.raises(ValueError, match="not a 'where' list"): + _almost_saving_in_where() + with pytest.raises(ValueError, match="goes in backticks"): + _almost_missing_backticks() + with pytest.raises(ValueError, match="backtick is unclosed"): + _almost_unclosed_backtick() + + def test_assert_aborted_where() -> None: world = World() aborted = Account.WithdrawAborted(OverdraftError(amount=20)) @@ -48,4 +130,69 @@ def test_assert_aborted_where() -> None: _assert_aborted(world, aborted, 'SomeOtherError', None) +def test_list_indices_build_requests() -> None: + world = World(client_types={'tests.reboot.bdd.Account': Account}) + # An [index] past a list of messages pads the list, so the + # elements below it are default-valued, proto's meaning of a + # present-but-unset message. + request = world.request( + state_type='Account', + method='set_owner', + properties={ + 'owner.name': 'F', + 'co_owners[1].name': 'x' + }, + ) + assert len(request.co_owners) == 2 + assert request.co_owners[0].name == '' + assert request.co_owners[1].name == 'x' + # A value the request type refuses prints the constructed JSON. + with pytest.raises(ValueError) as raised: + world.request( + state_type='Account', + method='deposit', + properties={'amount': 'abc'}, + ) + assert 'Could not build a `DepositRequest` from' in str(raised.value) + assert '"amount": "abc"' in str(raised.value) + + +def _properties( + properties: dict[str, JsonValue], +) -> list[tuple[PropertyPath, JsonValue]]: + return [ + (PropertyPath.create(text), value) + for text, value in properties.items() + ] + + +def test_assert_properties_proto_semantics() -> None: + _assert_properties( + BalanceResponse(balance=150), _properties({'balance': 150}) + ) + with pytest.raises(AssertionError): + _assert_properties( + BalanceResponse(balance=150), _properties({'balance': 151}) + ) + response = GetOwnerResponse(owner=Owner(name='Frank')) + _assert_properties(response, _properties({'owner': {'name': 'Frank'}})) + _assert_properties(response, _properties({'owner.name': 'Frank'})) + tagged = GetOwnerResponse(owner=Owner(name='Frank', tags=['vip', 'beta'])) + _assert_properties(tagged, _properties({'owner.tags[1]': 'beta'})) + with pytest.raises(AssertionError): + _assert_properties(tagged, _properties({'owner.tags[2]': 'x'})) + with pytest.raises(AssertionError): + _assert_properties( + response, + _properties({'owner': { + 'name': 'Frank', + 'tags': ['x'] + }}), + ) + with pytest.raises(AssertionError): + _assert_properties( + OpenResponse(account_id='150'), _properties({'account_id': 150}) + ) + + scenarios('accounts.feature') diff --git a/tests/reboot/bdd/pydantic/account_api.py b/tests/reboot/bdd/pydantic/account_api.py index 811757c16..d44418a04 100644 --- a/tests/reboot/bdd/pydantic/account_api.py +++ b/tests/reboot/bdd/pydantic/account_api.py @@ -2,10 +2,18 @@ tests run against.""" from reboot.api import API, Field, Methods, Model, Reader, Type, Writer +from typing import Optional + + +class Owner(Model): + name: str = Field(tag=1) + tags: list[str] = Field(tag=2, default_factory=list) class State(Model): balance: int = Field(tag=1, default=0) + owner: Optional[Owner] = Field(tag=2, default=None) + owners: dict[str, Owner] = Field(tag=3, default_factory=dict) class OpenRequest(Model): @@ -32,6 +40,25 @@ class BalanceResponse(Model): balance: int = Field(tag=1) +class SetOwnerRequest(Model): + owner: Owner = Field(tag=1) + # Owners in addition to `owner`. + co_owners: list[Owner] = Field(tag=2, default_factory=list) + + +class GetOwnerResponse(Model): + owner: Optional[Owner] = Field(tag=1) + + +class PutOwnerRequest(Model): + key: str = Field(tag=1) + owner: Owner = Field(tag=2) + + +class GetOwnersResponse(Model): + owners: dict[str, Owner] = Field(tag=1, default_factory=dict) + + class OverdraftError(Model): # Amount the withdrawal would have overdrafted the account by. amount: int = Field(tag=1) @@ -60,6 +87,26 @@ class OverdraftError(Model): response=BalanceResponse, mcp=None, ), + set_owner=Writer( + request=SetOwnerRequest, + response=None, + mcp=None, + ), + get_owner=Reader( + request=None, + response=GetOwnerResponse, + mcp=None, + ), + put_owner=Writer( + request=PutOwnerRequest, + response=None, + mcp=None, + ), + get_owners=Reader( + request=None, + response=GetOwnersResponse, + mcp=None, + ), ) api = API( diff --git a/tests/reboot/bdd/pydantic/account_servicer.py b/tests/reboot/bdd/pydantic/account_servicer.py index c2292908e..3484c4667 100644 --- a/tests/reboot/bdd/pydantic/account_servicer.py +++ b/tests/reboot/bdd/pydantic/account_servicer.py @@ -7,8 +7,12 @@ BalanceResponse, DepositRequest, DepositResponse, + GetOwnerResponse, + GetOwnersResponse, OpenRequest, OverdraftError, + PutOwnerRequest, + SetOwnerRequest, WithdrawRequest, WithdrawResponse, ) @@ -53,3 +57,29 @@ async def balance( context: ReaderContext, ) -> BalanceResponse: return BalanceResponse(balance=self.state.balance) + + async def set_owner( + self, + context: WriterContext, + request: SetOwnerRequest, + ) -> None: + self.state.owner = request.owner + + async def get_owner( + self, + context: ReaderContext, + ) -> GetOwnerResponse: + return GetOwnerResponse(owner=self.state.owner) + + async def put_owner( + self, + context: WriterContext, + request: PutOwnerRequest, + ) -> None: + self.state.owners = {**self.state.owners, request.key: request.owner} + + async def get_owners( + self, + context: ReaderContext, + ) -> GetOwnersResponse: + return GetOwnersResponse(owners=self.state.owners) diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index 738832fcb..b8c213230 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -20,3 +20,21 @@ Feature: Accounts with a pydantic API Given an `Account` for "carol" gets created via `open` with `initial_balance=10` When "carol" makes 3 deposits of 7 Then `balance` on the `Account` for "carol" has `balance=31` + + Scenario: Properties can be messages + Given an `Account` for "frank" gets created via `open` + When the `Account` for "frank" gets a `set_owner` with `owner={name: "Frank", tags: ["vip", "beta"]}` + Then `get_owner` on the `Account` for "frank" has `owner={name: "Frank", tags: ["vip", "beta"]}` + When the `Account` for "frank" gets a `set_owner` with `owner.name="Frankie"` and `owner.tags=["pro"]` + Then `get_owner` on the `Account` for "frank" has `owner={name: "Frankie", tags: ["pro"]}` + And `get_owner` on the `Account` for "frank" has `owner.tags[0]="pro"` + When `get_owner` on the `Account` for "frank" has `owner` saved as "$owner" + And an `Account` for "franklin" gets created via `open` + And the `Account` for "franklin" gets a `set_owner` with `owner=$owner` + Then `get_owner` on the `Account` for "franklin" has `owner={name: "Frankie", tags: ["pro"]}` + + Scenario: Properties reach through maps + Given an `Account` for "heidi" gets created via `open` + When the `Account` for "heidi" gets a `put_owner` with `key="main"` and `owner={name: "Heidi", tags: ["a"]}` + Then `get_owners` on the `Account` for "heidi" has `owners["main"].name="Heidi"` + And `get_owners` on the `Account` for "heidi" has `owners={main: {name: "Heidi", tags: ["a"]}}` diff --git a/tests/reboot/bdd/pydantic/bdd_tests.py b/tests/reboot/bdd/pydantic/bdd_tests.py index c654036c8..0ebb2c565 100644 --- a/tests/reboot/bdd/pydantic/bdd_tests.py +++ b/tests/reboot/bdd/pydantic/bdd_tests.py @@ -7,10 +7,18 @@ # # ruff: noqa: F403 +import pytest from pytest_bdd import parsers, scenarios from reboot.bdd import when -from reboot.bdd.fixtures import World +from reboot.bdd.fixtures import JsonValue, PropertyPath, World from reboot.bdd.steps import * +from reboot.bdd.steps import _assert_properties +from tests.reboot.bdd.pydantic.account_api import ( + GetOwnerResponse, + GetOwnersResponse, + Owner, +) +from tests.reboot.bdd.pydantic.account_api_rbt import Account # A custom `async def` step, the way a developer would write one: it @@ -32,4 +40,132 @@ async def _makes_deposits( ) +def test_unknown_property_raises() -> None: + world = World(client_types={'tests.reboot.bdd.pydantic.Account': Account}) + with pytest.raises(ValueError) as raised: + world.request( + state_type='Account', + method='deposit', + properties={'amunt': 50}, + ) + assert 'has no property `amunt`' in str(raised.value) + + +def test_list_indices_build_requests() -> None: + world = World(client_types={'tests.reboot.bdd.pydantic.Account': Account}) + request = world.request( + state_type='Account', + method='set_owner', + properties={ + 'owner.name': 'Frank', + 'owner.tags[0]': 'a', + 'owner.tags[1]': 'b', + }, + ) + assert request.owner.tags == ['a', 'b'] + # An [index] past a scalar list's end pads the list with `{}` + # placeholders, the wrong type for its elements. + with pytest.raises(ValueError) as raised: + world.request( + state_type='Account', + method='set_owner', + properties={ + 'owner.name': 'F', + 'owner.tags[1]': 'b' + }, + ) + assert 'not the same type' in str(raised.value) + # An element of a different type than the list's. + with pytest.raises(ValueError) as raised: + world.request( + state_type='Account', + method='set_owner', + properties={ + 'owner.tags[0]': 'a', + 'owner.tags[1]': 5 + }, + ) + assert 'not the same type' in str(raised.value) + # An [index] past a list of models pads with default-valued + # elements, which `Owner`'s required `name` refuses, printing the + # constructed JSON. + with pytest.raises(ValueError) as raised: + world.request( + state_type='Account', + method='set_owner', + properties={ + 'owner.name': 'F', + 'co_owners[1].name': 'x' + }, + ) + assert 'Could not build a `SetOwnerRequest` from' in str(raised.value) + assert '"co_owners": [{}, {"name": "x"}]' in str(raised.value) + + +def test_colliding_properties_raise() -> None: + world = World(client_types={'tests.reboot.bdd.pydantic.Account': Account}) + with pytest.raises(ValueError) as raised: + world.request( + state_type='Account', + method='set_owner', + properties={ + 'owner': { + 'name': 'a' + }, + 'owner.name': 'b' + }, + ) + assert 'collides' in str(raised.value) + # As a list of pairs, the way the built-in steps call, even a + # literal repeat of one property collides. + with pytest.raises(ValueError) as raised: + world.request( + state_type='Account', + method='set_owner', + properties=[ + (PropertyPath.create('owner.name'), 'a'), + (PropertyPath.create('owner.name'), 'b'), + ], + ) + assert 'collides' in str(raised.value) + + +def _properties( + properties: dict[str, JsonValue], +) -> list[tuple[PropertyPath, JsonValue]]: + return [ + (PropertyPath.create(text), value) + for text, value in properties.items() + ] + + +def test_assert_properties_pydantic_semantics() -> None: + response = GetOwnerResponse(owner=Owner(name='Frank')) + _assert_properties(response, _properties({'owner': {'name': 'Frank'}})) + _assert_properties(response, _properties({'owner.name': 'Frank'})) + with pytest.raises(AssertionError): + _assert_properties( + response, + _properties({'owner': { + 'name': 'Frank', + 'tags': ['x'] + }}), + ) + _assert_properties( + GetOwnerResponse(owner=None), _properties({'owner': None}) + ) + owners = GetOwnersResponse(owners={'main': Owner(name='Heidi')}) + _assert_properties(owners, _properties({'owners["main"].name': 'Heidi'})) + _assert_properties(owners, _properties({'owners.main.name': 'Heidi'})) + _assert_properties( + owners, _properties({'owners': { + 'main': { + 'name': 'Heidi' + } + }}) + ) + with pytest.raises(AssertionError): + _assert_properties(owners, _properties({'owners': {}})) + + scenarios('accounts.feature') From 228237e3098416bc3489518c5b65fa08db8e64bc Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 18:42:53 +0000 Subject: [PATCH 06/42] Type `reboot.bdd` clauses as `Assignment` and `Equals` A call's 'with' list now parses to `Assignment`s, the value to put at a property's path when building the request, and an asserting list to `Assertion`s, for now just `Equals`, the value a property must equal under the response type's semantics. `Assignment` and `Equals` share their shape, a `PropertyPath` and a JSON value, and deliberately not a type: one builds and one compares, and each name reads at its call sites. `World.request` and `World.call` take a `dict[str, JsonValue]` from a developer's own step or a list of `Assignment`s from the built-in steps. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/fixtures.py | 66 +++++++++------- reboot/bdd/steps.py | 103 +++++++++++++++++-------- tests/reboot/bdd/bdd_tests.py | 42 +++++----- tests/reboot/bdd/pydantic/bdd_tests.py | 46 +++++------ 4 files changed, 157 insertions(+), 100 deletions(-) diff --git a/reboot/bdd/fixtures.py b/reboot/bdd/fixtures.py index dd59cfd69..f24617e49 100644 --- a/reboot/bdd/fixtures.py +++ b/reboot/bdd/fixtures.py @@ -84,6 +84,18 @@ def create(text: str) -> 'PropertyPath': return PropertyPath(text=text, expression=expression) +@dataclass(frozen=True) +class Assignment: + """A `path=value` clause in a call's 'with' list: the value put + at the property's path when building the request.""" + + # The property assigned. + path: PropertyPath + + # The value put there, as written (JSON). + value: JsonValue + + def _json_type(value: Any) -> type: """The JSON type of a value: `int` and `float` are one number type, and `bool` is its own.""" @@ -235,23 +247,22 @@ def request( *, state_type: str, method: str, - properties: Union[dict[str, JsonValue], list[tuple[PropertyPath, - JsonValue]]], + assignments: Union[dict[str, JsonValue], list[Assignment]], ) -> Any: - """The request the properties describe, validated by the named - method's request type. A dotted property name nests, e.g. - 'owner.name' describes the request's `owner` message's - `name`.""" + """Returns a request with properties derived from the assignments, + validated by the named method's request type. A dotted + property name nests, e.g., 'owner.name' describes the + request's `owner` message's `name`.""" request_type = self.request_type(state_type=state_type, method=method) if request_type is None: raise ValueError( f"`{state_type}`'s `{method}` takes no properties" ) - if isinstance(properties, dict): - properties = [ - (PropertyPath.create(text), value) - for text, value in properties.items() + if isinstance(assignments, dict): + assignments = [ + Assignment(path=PropertyPath.create(text), value=value) + for text, value in assignments.items() ] # Build the JSON object a property at a time, where a property @@ -283,13 +294,14 @@ def confirmed(expression: jsonpath_ng.JSONPath) -> None: confirmed(path.expression) - for path, value in properties: + for assignment in assignments: # For creating a JSON object we disallow certain kinds of # paths that just don't make sense or are not useful. - validate(path) - if path.expression.find(result): + validate(assignment.path) + if assignment.path.expression.find(result): raise ValueError( - f"Property `{path.text}` collides with another property" + f"Property `{assignment.path.text}` collides with " + "another property" ) def update( @@ -316,9 +328,11 @@ def update( for index, element in enumerate(parent): if index == field: continue - if _json_type(element) is not _json_type(value): + if _json_type(element) is not _json_type( + assignment.value + ): raise ValueError( - f"Property `{path.text}` indexes " + f"Property `{assignment.path.text}` indexes " f"into a list whose element " f"{element!r} is not the same type " "as its value" @@ -327,8 +341,8 @@ def update( # returning a value does not always store it correctly # so we need to store it ourselves and return it until # h2non/jsonpath-ng#238 gets fixed. - parent[field] = value - return value + parent[field] = assignment.value + return assignment.value try: # NOTE: we are using the version of `update_or_create` @@ -336,10 +350,11 @@ def update( # jsonpath-ng to raise a KeyError if a path attempts # to do a list index in an already existing dict # (i.e., treating the dict like a list incorrectly). - path.expression.update_or_create(result, update) + assignment.path.expression.update_or_create(result, update) except (KeyError, TypeError) as error: raise ValueError( - f"Property `{path.text}` cannot be applied to " + f"Property `{assignment.path.text}` cannot be " + "applied to " "what is already built" ) from error # If the request is a Pydantic model, we use `model_validate`. @@ -376,23 +391,22 @@ async def call( state_type: str, state_id: str, method: str, - properties: Union[dict[str, JsonValue], list[tuple[PropertyPath, - JsonValue]]], + assignments: Union[dict[str, JsonValue], list[Assignment]], ) -> Any: - """Calls the named method on the named state, with the - properties as the request's, and returns its response.""" + """Returns the response from calling the named method on the named + state using the specified `assignments` to create a request.""" reference = self.client_type(state_type).ref(state_id) method_callable = getattr(reference, method, None) if not callable(method_callable): raise ValueError(f"`{state_type}` has no method `{method}`") - if not properties: + if not assignments: return await method_callable(self.context()) return await method_callable( self.context(), self.request( state_type=state_type, method=method, - properties=properties, + assignments=assignments, ), ) diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 995462aea..570673277 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -56,6 +56,7 @@ def application() -> Application: import jsonpath_ng import pytest import re +from dataclasses import dataclass from google.protobuf import json_format from google.protobuf.message import Message # Re-exported so that `from reboot.bdd.steps import *` brings in the @@ -68,6 +69,7 @@ def application() -> Application: from reboot.api import Model from reboot.bdd import given, then, when from reboot.bdd.fixtures import ( + Assignment, JsonValue, PropertyPath, World, @@ -129,6 +131,22 @@ def application() -> Application: # on. _STATE = r'the `(?P[\w.]+)` for "(?P[^"]*)"' + +@dataclass(frozen=True) +class Equals: + """A `path=value` clause in an asserting list: the property + equals the value under the response type's semantics.""" + + # The property asserted on. + path: PropertyPath + + # The value it must equal, as written (JSON). + value: JsonValue + + +# What one clause of an asserting list parses to. +Assertion = Equals + # A step's optional trailing property list. _PROPERTIES = rf'(?: with (?P{_PROPERTY_CLAUSES}))?' @@ -191,18 +209,18 @@ def _almost_save_message(clause: str) -> str: ) -def _parse_properties( +def _parse_assignments( world: World, clauses: Optional[str], -) -> list[tuple[PropertyPath, JsonValue]]: - """Parses a step's property list, e.g. '`amount=50` and - `reason="promo"`', into a dictionary of JSON values; a property - value of the form '$name' becomes the saved value going by that - name. The step patterns admit lexical near-misses of a clause, - so each clause is confirmed strict here, raising the fix.""" - parsed: list[tuple[PropertyPath, JsonValue]] = [] +) -> list[Assignment]: + """Parses a call's 'with' list, e.g. '`amount=50` and + `reason="promo"`', into `Assignment`s; a property value of the + form '$name' becomes the saved value going by that name. The step + patterns admit lexical near-misses of a clause, so each clause is + confirmed strict here, raising the fix.""" + assignments: list[Assignment] = [] if clauses is None: - return parsed + return assignments for clause_match in re.finditer(_PROPERTY_CLAUSE, clauses): property_match = _PROPERTY_PATTERN.fullmatch(clause_match[0]) if property_match is None: @@ -219,8 +237,13 @@ def _parse_properties( '{name: "value"}, but got: ' f"{property_match['value']}" ) from error - parsed.append((PropertyPath.create(property_match['path']), value)) - return parsed + assignments.append( + Assignment( + path=PropertyPath.create(property_match['path']), + value=value, + ) + ) + return assignments def _parse_saves(clauses: str) -> dict[str, PropertyPath]: @@ -414,25 +437,25 @@ def _pydantic_property_matches( def _assert_properties( subject: Union[Message, Model], - properties: list[tuple[PropertyPath, JsonValue]], + assertions: list[Assertion], ) -> None: - """Asserts that each of the given property paths reaches the - expected value on the given response or error, comparing under - the subject type's semantics.""" + """Asserts that each of the given assertions holds on the given + response or error, comparing under the subject type's + semantics.""" subject_json = _json_object(subject) - for path, expected in properties: - actual = _resolve_json_property(subject_json, path) + for assertion in assertions: + actual = _resolve_json_property(subject_json, assertion.path) if isinstance(subject, Message): matches = _proto_property_matches( - type(subject), path, actual, expected + type(subject), assertion.path, actual, assertion.value ) else: matches = _pydantic_property_matches( - type(subject), path, actual, expected + type(subject), assertion.path, actual, assertion.value ) assert matches, ( - f"Expected `{path.text}` to be {expected!r}, but it is " - f"{actual!r}" + f"Expected `{assertion.path.text}` to be " + f"{assertion.value!r}, but it is {actual!r}" ) @@ -474,12 +497,12 @@ async def _gets_created_via( clauses: Optional[str], ) -> None: factory = world.factory(state_type=state_type, method=method) - properties = _parse_properties(world, clauses) + assignments = _parse_assignments(world, clauses) arguments = [world.context(), _resolve_state_id(world, state_id)] - if properties: + if assignments: arguments.append( world.request( - state_type=state_type, method=method, properties=properties + state_type=state_type, method=method, assignments=assignments ) ) try: @@ -510,7 +533,7 @@ async def _gets_a( state_type=state_type, state_id=_resolve_state_id(world, state_id), method=method, - properties=_parse_properties(world, clauses), + assignments=_parse_assignments(world, clauses), ) except Aborted as aborted: raise AssertionError( @@ -540,7 +563,7 @@ async def _attempts_a( state_type=state_type, state_id=_resolve_state_id(world, state_id), method=method, - properties=_parse_properties(world, clauses), + assignments=_parse_assignments(world, clauses), ) world.aborted = None except Aborted as aborted: @@ -560,7 +583,13 @@ def _assert_aborted( f"Expected an abort with `{error_type}`, but it aborted " f"with `{type(error).__name__}`: {aborted}" ) - _assert_properties(error, _parse_properties(world, clauses)) + _assert_properties( + error, + [ + Equals(path=assignment.path, value=assignment.value) + for assignment in _parse_assignments(world, clauses) + ], + ) @then( @@ -599,7 +628,7 @@ async def _read( state_type=state_type, state_id=_resolve_state_id(world, state_id), method=method, - properties={}, + assignments={}, ) return world.response except Aborted as aborted: @@ -623,7 +652,13 @@ async def _then_has( clauses: str, ) -> None: response = await _read(world, method, state_type, state_id) - _assert_properties(response, _parse_properties(world, clauses)) + _assert_properties( + response, + [ + Equals(path=assignment.path, value=assignment.value) + for assignment in _parse_assignments(world, clauses) + ], + ) @given( @@ -677,7 +712,7 @@ async def _aborts_with( state_type=state_type, state_id=_resolve_state_id(world, state_id), method=method, - properties={}, + assignments={}, ) except Aborted as aborted: _assert_aborted(world, aborted, error_type, clauses) @@ -695,7 +730,13 @@ def _the_result_has(world: World, clauses: str) -> None: "Expected a preceding step to have made a call that returned " "a response, but there is none" ) - _assert_properties(world.response, _parse_properties(world, clauses)) + _assert_properties( + world.response, + [ + Equals(path=assignment.path, value=assignment.value) + for assignment in _parse_assignments(world, clauses) + ], + ) @given( diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index 05873b52f..bb5f707d8 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -17,6 +17,8 @@ _MIXED_CLAUSES, _PROPERTY_CLAUSES, _SAVE_CLAUSES, + Assertion, + Equals, _almost_asserting_under_given_or_when, _almost_missing_backticks, _almost_mixing_clauses, @@ -26,7 +28,7 @@ _almost_unclosed_backtick, _assert_aborted, _assert_properties, - _parse_properties, + _parse_assignments, _parse_saves, ) from tests.reboot.bdd.account_pb2 import ( @@ -83,16 +85,16 @@ def test_clause_grammar_routing() -> None: def test_almost_clause_messages() -> None: world = World() with pytest.raises(ValueError, match="with '=', not ':'"): - _parse_properties(world, '`amount: 50`') + _parse_assignments(world, '`amount: 50`') with pytest.raises(ValueError, match="without spaces around the '='"): - _parse_properties(world, '`amount = 50`') + _parse_assignments(world, '`amount = 50`') with pytest.raises(ValueError, match="without spaces around the '='"): - _parse_properties(world, '`amount= 50`') + _parse_assignments(world, '`amount= 50`') with pytest.raises(ValueError, match="the value is missing"): - _parse_properties(world, '`amount=`') + _parse_assignments(world, '`amount=`') with pytest.raises(ValueError, match="must be JSON"): - _parse_properties(world, '`amount=abc`') - assert _parse_properties(world, '`owner={name: "F"}`')[0][1] == { + _parse_assignments(world, '`amount=abc`') + assert _parse_assignments(world, '`owner={name: "F"}`')[0].value == { 'name': 'F' } with pytest.raises(ValueError, match="'saved as', not 'saved to'"): @@ -138,7 +140,7 @@ def test_list_indices_build_requests() -> None: request = world.request( state_type='Account', method='set_owner', - properties={ + assignments={ 'owner.name': 'F', 'co_owners[1].name': 'x' }, @@ -151,47 +153,47 @@ def test_list_indices_build_requests() -> None: world.request( state_type='Account', method='deposit', - properties={'amount': 'abc'}, + assignments={'amount': 'abc'}, ) assert 'Could not build a `DepositRequest` from' in str(raised.value) assert '"amount": "abc"' in str(raised.value) -def _properties( +def _assertions( properties: dict[str, JsonValue], -) -> list[tuple[PropertyPath, JsonValue]]: +) -> list[Assertion]: return [ - (PropertyPath.create(text), value) + Equals(path=PropertyPath.create(text), value=value) for text, value in properties.items() ] def test_assert_properties_proto_semantics() -> None: _assert_properties( - BalanceResponse(balance=150), _properties({'balance': 150}) + BalanceResponse(balance=150), _assertions({'balance': 150}) ) with pytest.raises(AssertionError): _assert_properties( - BalanceResponse(balance=150), _properties({'balance': 151}) + BalanceResponse(balance=150), _assertions({'balance': 151}) ) response = GetOwnerResponse(owner=Owner(name='Frank')) - _assert_properties(response, _properties({'owner': {'name': 'Frank'}})) - _assert_properties(response, _properties({'owner.name': 'Frank'})) + _assert_properties(response, _assertions({'owner': {'name': 'Frank'}})) + _assert_properties(response, _assertions({'owner.name': 'Frank'})) tagged = GetOwnerResponse(owner=Owner(name='Frank', tags=['vip', 'beta'])) - _assert_properties(tagged, _properties({'owner.tags[1]': 'beta'})) + _assert_properties(tagged, _assertions({'owner.tags[1]': 'beta'})) with pytest.raises(AssertionError): - _assert_properties(tagged, _properties({'owner.tags[2]': 'x'})) + _assert_properties(tagged, _assertions({'owner.tags[2]': 'x'})) with pytest.raises(AssertionError): _assert_properties( response, - _properties({'owner': { + _assertions({'owner': { 'name': 'Frank', 'tags': ['x'] }}), ) with pytest.raises(AssertionError): _assert_properties( - OpenResponse(account_id='150'), _properties({'account_id': 150}) + OpenResponse(account_id='150'), _assertions({'account_id': 150}) ) diff --git a/tests/reboot/bdd/pydantic/bdd_tests.py b/tests/reboot/bdd/pydantic/bdd_tests.py index 0ebb2c565..9f6b77989 100644 --- a/tests/reboot/bdd/pydantic/bdd_tests.py +++ b/tests/reboot/bdd/pydantic/bdd_tests.py @@ -10,9 +10,9 @@ import pytest from pytest_bdd import parsers, scenarios from reboot.bdd import when -from reboot.bdd.fixtures import JsonValue, PropertyPath, World +from reboot.bdd.fixtures import Assignment, JsonValue, PropertyPath, World from reboot.bdd.steps import * -from reboot.bdd.steps import _assert_properties +from reboot.bdd.steps import Assertion, Equals, _assert_properties from tests.reboot.bdd.pydantic.account_api import ( GetOwnerResponse, GetOwnersResponse, @@ -36,7 +36,7 @@ async def _makes_deposits( state_type='Account', state_id=state_id, method='deposit', - properties={'amount': amount}, + assignments={'amount': amount}, ) @@ -46,7 +46,7 @@ def test_unknown_property_raises() -> None: world.request( state_type='Account', method='deposit', - properties={'amunt': 50}, + assignments={'amunt': 50}, ) assert 'has no property `amunt`' in str(raised.value) @@ -56,7 +56,7 @@ def test_list_indices_build_requests() -> None: request = world.request( state_type='Account', method='set_owner', - properties={ + assignments={ 'owner.name': 'Frank', 'owner.tags[0]': 'a', 'owner.tags[1]': 'b', @@ -69,7 +69,7 @@ def test_list_indices_build_requests() -> None: world.request( state_type='Account', method='set_owner', - properties={ + assignments={ 'owner.name': 'F', 'owner.tags[1]': 'b' }, @@ -80,7 +80,7 @@ def test_list_indices_build_requests() -> None: world.request( state_type='Account', method='set_owner', - properties={ + assignments={ 'owner.tags[0]': 'a', 'owner.tags[1]': 5 }, @@ -93,7 +93,7 @@ def test_list_indices_build_requests() -> None: world.request( state_type='Account', method='set_owner', - properties={ + assignments={ 'owner.name': 'F', 'co_owners[1].name': 'x' }, @@ -108,7 +108,7 @@ def test_colliding_properties_raise() -> None: world.request( state_type='Account', method='set_owner', - properties={ + assignments={ 'owner': { 'name': 'a' }, @@ -122,50 +122,50 @@ def test_colliding_properties_raise() -> None: world.request( state_type='Account', method='set_owner', - properties=[ - (PropertyPath.create('owner.name'), 'a'), - (PropertyPath.create('owner.name'), 'b'), + assignments=[ + Assignment(path=PropertyPath.create('owner.name'), value='a'), + Assignment(path=PropertyPath.create('owner.name'), value='b'), ], ) assert 'collides' in str(raised.value) -def _properties( +def _assertions( properties: dict[str, JsonValue], -) -> list[tuple[PropertyPath, JsonValue]]: +) -> list[Assertion]: return [ - (PropertyPath.create(text), value) + Equals(path=PropertyPath.create(text), value=value) for text, value in properties.items() ] def test_assert_properties_pydantic_semantics() -> None: response = GetOwnerResponse(owner=Owner(name='Frank')) - _assert_properties(response, _properties({'owner': {'name': 'Frank'}})) - _assert_properties(response, _properties({'owner.name': 'Frank'})) + _assert_properties(response, _assertions({'owner': {'name': 'Frank'}})) + _assert_properties(response, _assertions({'owner.name': 'Frank'})) with pytest.raises(AssertionError): _assert_properties( response, - _properties({'owner': { + _assertions({'owner': { 'name': 'Frank', 'tags': ['x'] }}), ) _assert_properties( - GetOwnerResponse(owner=None), _properties({'owner': None}) + GetOwnerResponse(owner=None), _assertions({'owner': None}) ) owners = GetOwnersResponse(owners={'main': Owner(name='Heidi')}) - _assert_properties(owners, _properties({'owners["main"].name': 'Heidi'})) - _assert_properties(owners, _properties({'owners.main.name': 'Heidi'})) + _assert_properties(owners, _assertions({'owners["main"].name': 'Heidi'})) + _assert_properties(owners, _assertions({'owners.main.name': 'Heidi'})) _assert_properties( - owners, _properties({'owners': { + owners, _assertions({'owners': { 'main': { 'name': 'Heidi' } }}) ) with pytest.raises(AssertionError): - _assert_properties(owners, _properties({'owners': {}})) + _assert_properties(owners, _assertions({'owners': {}})) scenarios('accounts.feature') From 101c9f6faff05f3ec315c8e934fbfa2e45bac58f Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 19:05:02 +0000 Subject: [PATCH 07/42] Let `reboot.bdd` assertions say `containing` and `of length` An asserting clause list, a Then 'has' and an abort's clause list, now takes predicates alongside `path=value` equalities: `path` containing 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, and `path` of length asserts the length of a string, list, or map. Predicate arguments are scalar JSON (or a "$name" recall); containing an object element can come later with its own delimitation. Each clause parses to a `Containing` or `OfLength` alongside `Equals`, growing the `Assertion` union the asserting steps dispatch on. The abort clause list's keyword is now 'with', not 'where', so that a predicate always conjugates as 'containing': Then the attempt aborts with `OverdraftError` with `amount=20` Then `get_owner` on ... has `owner.name` containing "rank" The near-miss net extends to match: 'contains' for 'containing', a missing 'of', a non-integer length, predicates in a call's 'with', and predicates in a saving 'has' all raise an 'Almost' naming the fix. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/steps.py | 363 +++++++++++++++++---- tests/reboot/bdd/accounts.feature | 5 +- tests/reboot/bdd/bdd_tests.py | 82 ++++- tests/reboot/bdd/pydantic/accounts.feature | 5 +- tests/reboot/bdd/pydantic/bdd_tests.py | 34 +- 5 files changed, 421 insertions(+), 68 deletions(-) diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 570673277..1dedcb056 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -35,9 +35,12 @@ def application() -> Application: way 'has' refuses writers, and a reader's abort is asserted with '`reader` on ... aborts with ...'. -A 'has' or 'where' list can also save a property under a name, -which later steps say as `$name`, in a state's ID or as a property -value (a quoted "$name" stays the literal string): +An asserting list can also say the predicates `path` containing + (a substring of a string, an element of a list, or a key of +a map) and `path` of length . A Given or When 'has' instead +saves a property under a name, which later steps say as `$name`, in +a state's ID or as a property value (a quoted "$name" stays the +literal string): When `get_owner` on the `Account` for "frank" has `owner.name` saved as "$owner_name" @@ -105,14 +108,44 @@ def application() -> Application: _SAVE_CLAUSE = rf'`{_PATH}`\s+saved\s+(?:as|to)\s+"?\$?\w+"?' _SAVE_PATTERN = re.compile(rf'`(?P{_PATH})` saved as "\$(?P\w+)"') +# A predicate clause's argument: a scalar JSON value (a quoted +# string may contain separators), or a '$name' recall. +_ARGUMENT = r'(?:"(?:[^"\\]|\\.)*"|\$\w+|[-+.\w]+)' + +# One containing clause: asserts a substring of a string, an element +# of a list, or a key of a map. The groupless form embeds in step +# patterns and also matches 'contains', so that near-miss routes to +# a step whose parser raises the fix; the compiled form is the +# strict shape, for extraction. +_CONTAINING_CLAUSE = rf'`{_PATH}`\s+contain(?:s|ing)\s+{_ARGUMENT}' +_CONTAINING_PATTERN = re.compile( + rf'`(?P{_PATH})` containing (?P{_ARGUMENT})' +) + +# One length clause: asserts the length of a string, list, or map. +# The groupless form embeds in step patterns and also matches a +# missing 'of' or a non-integer length, for diagnosis; the compiled +# form is the strict shape, for extraction. +_LENGTH_CLAUSE = rf'`{_PATH}`\s+(?:of\s+)?length\s+{_ARGUMENT}' +_LENGTH_PATTERN = re.compile(rf'`(?P{_PATH})` of length (?P\d+)') + # What separates two clauses in step text: a comma, an 'and', or a # comma followed by an 'and'. _SEPARATOR = r'\s*(?:,\s*and|,|and)\s+' -# A clause list of only 'path=value' properties: what a 'with' -# passes to a call, and what a Then 'has'/'where' asserts. +# A clause list of only 'path=value' properties: what a call's +# 'with' passes. _PROPERTY_CLAUSES = rf'{_PROPERTY_CLAUSE}(?:{_SEPARATOR}{_PROPERTY_CLAUSE})*' +# One asserting clause: an equality or a predicate. +_ASSERT_CLAUSE = ( + rf'(?:{_PROPERTY_CLAUSE}|{_CONTAINING_CLAUSE}|{_LENGTH_CLAUSE})' +) + +# A clause list of asserting clauses: what a Then 'has' and an +# abort's 'with' assert. +_ASSERT_CLAUSES = rf'{_ASSERT_CLAUSE}(?:{_SEPARATOR}{_ASSERT_CLAUSE})*' + # A clause list of only saving clauses: what a Given or When 'has' # saves. _SAVE_CLAUSES = rf'{_SAVE_CLAUSE}(?:{_SEPARATOR}{_SAVE_CLAUSE})*' @@ -121,9 +154,11 @@ def application() -> Application: # so the mistake gets a pointed error instead of an unmatched step. # A property value can never contain a backtick, so the lookaheads # can only hit an actual clause of each kind. -_CLAUSE = rf'(?:{_PROPERTY_CLAUSE}|{_SAVE_CLAUSE})' +_CLAUSE = rf'(?:{_ASSERT_CLAUSE}|{_SAVE_CLAUSE})' _MIXED_CLAUSES = ( - rf'(?=.*`\s+saved\s)(?=.*`{_PATH}\s*[:=])' + rf'(?=.*`\s+saved\s)' + rf'(?=.*(?:`{_PATH}\s*[:=]|`{_PATH}`\s+contain|' + rf'`{_PATH}`\s+(?:of\s+)?length))' rf'{_CLAUSE}(?:{_SEPARATOR}{_CLAUSE})*' ) @@ -144,8 +179,32 @@ class Equals: value: JsonValue +@dataclass(frozen=True) +class Containing: + """A `path` containing clause: a substring of a string, + an element of a list, or a key of a map.""" + + # The property asserted on. + path: PropertyPath + + # The substring, element, or key; a scalar. + value: JsonValue + + +@dataclass(frozen=True) +class OfLength: + """A `path` of length clause: the length of a string, list, + or map.""" + + # The property asserted on. + path: PropertyPath + + # The length asserted. + length: int + + # What one clause of an asserting list parses to. -Assertion = Equals +Assertion = Union[Equals, Containing, OfLength] # A step's optional trailing property list. _PROPERTIES = rf'(?: with (?P{_PROPERTY_CLAUSES}))?' @@ -191,6 +250,33 @@ def _almost_property_message(clause: str) -> str: return f"Expected a property of the form `path=value`, but got: {clause}" +def _almost_containing_message(clause: str) -> str: + """The 'Almost' error for a containing clause that is a lexical + near-miss of `path` containing .""" + if re.search(r'\bcontains\b', clause): + return f"Almost: say 'containing', not 'contains': {clause}" + return ( + "Expected a containing clause of the form `path` containing " + f'"value", but got: {clause}' + ) + + +def _almost_length_message(clause: str) -> str: + """The 'Almost' error for a length clause that is a lexical + near-miss of `path` of length .""" + if re.search(r'`\s+length\b', clause): + return f"Almost: say 'of length', not 'length': {clause}" + if not re.search(r'\blength\s+\d+$', clause): + return ( + "Almost: 'of length' takes a whole number, e.g. of " + f"length 2: {clause}" + ) + return ( + "Expected a length clause of the form `path` of length 2, " + f"but got: {clause}" + ) + + def _almost_save_message(clause: str) -> str: """The 'Almost' error for a saving clause that is a lexical near-miss of `name` saved as "$name".""" @@ -246,6 +332,82 @@ def _parse_assignments( return assignments +def _parsed_argument(world: World, argument: str) -> JsonValue: + """The JSON value a predicate clause's argument says; a '$name' + becomes the saved value going by that name.""" + if re.fullmatch(r'\$\w+', argument): + return _saved_value(world, argument[1:]) + try: + return json5.loads(argument) + except ValueError as error: + raise ValueError( + f"The argument {argument} must be JSON, e.g. 50, 2.5, " + '"text", or true' + ) from error + + +def _parse_assertions( + world: World, + clauses: Optional[str], +) -> list[Assertion]: + """Parses a Then 'has' or abort 'with' clause list into + `Assertion`s: `Equals` for `path=value`, `Containing` for + `path` containing , and `OfLength` for `path` of length + . The step patterns admit lexical near-misses of a clause, + so each clause is confirmed strict here, raising the fix.""" + assertions: list[Assertion] = [] + if clauses is None: + return assertions + for clause_match in re.finditer(_ASSERT_CLAUSE, clauses): + clause = clause_match[0] + containing_match = _CONTAINING_PATTERN.fullmatch(clause) + if containing_match is not None: + assertions.append( + Containing( + path=PropertyPath.create(containing_match['path']), + value=_parsed_argument( + world, containing_match['argument'] + ), + ) + ) + continue + length_match = _LENGTH_PATTERN.fullmatch(clause) + if length_match is not None: + assertions.append( + OfLength( + path=PropertyPath.create(length_match['path']), + length=int(length_match['length']), + ) + ) + continue + if re.search(r'\bcontain', clause): + raise ValueError(_almost_containing_message(clause)) + if re.search(r'\blength\b', clause): + raise ValueError(_almost_length_message(clause)) + property_match = _PROPERTY_PATTERN.fullmatch(clause) + if property_match is None: + raise ValueError(_almost_property_message(clause)) + if re.fullmatch(r'\$\w+', property_match['value']): + value = _saved_value(world, property_match['value'][1:]) + else: + try: + value = json5.loads(property_match['value']) + except ValueError as error: + raise ValueError( + f"The value of `{property_match['path']}` must " + "be JSON, e.g. 50, 2.5, \"text\", true, or " + '{name: "value"}, but got: ' + f"{property_match['value']}" + ) from error + assertions.append( + Equals( + path=PropertyPath.create(property_match['path']), + value=value, + ) + ) + return assertions + + def _parse_saves(clauses: str) -> dict[str, PropertyPath]: """Parses a Given or When 'has' list of saving clauses, e.g. '`amount` saved as "$amount"', into the property to save under @@ -435,28 +597,113 @@ def _pydantic_property_matches( ) from error +def _property_matches( + subject: Union[Message, Model], + path: PropertyPath, + actual: JsonValue, + expected: JsonValue, +) -> bool: + """Whether the actual value of the property equals the expected + JSON value under the subject type's semantics.""" + if isinstance(subject, Message): + return _proto_property_matches(type(subject), path, actual, expected) + return _pydantic_property_matches(type(subject), path, actual, expected) + + +def _element_path(path: PropertyPath) -> PropertyPath: + """The path of the given list property's element.""" + return PropertyPath( + text=f'{path.text}[0]', + expression=jsonpath_ng.Child(path.expression, jsonpath_ng.Index(0)), + ) + + +def _assert_containing( + subject: Union[Message, Model], + path: PropertyPath, + actual: JsonValue, + argument: JsonValue, +) -> None: + """Asserts the containing predicate on the property's actual + value: a substring of a string, an element of a list (compared + under the subject type's semantics), or a key of a map.""" + if isinstance(actual, str): + if not isinstance(argument, str): + raise ValueError( + f"`{path.text}` is a string, so containing takes a " + f"string, but got: {argument!r}" + ) + assert argument in actual, ( + f"Expected `{path.text}` to contain {argument!r}, but " + f"it is {actual!r}" + ) + return + if isinstance(actual, list): + element = _element_path(path) + assert any( + _property_matches(subject, element, value, argument) + for value in actual + ), ( + f"Expected `{path.text}` to contain {argument!r}, but " + f"it is {actual!r}" + ) + return + if isinstance(actual, dict): + if not isinstance(argument, str): + raise ValueError( + f"`{path.text}` is a map, so containing takes a " + f"string key, but got: {argument!r}" + ) + assert argument in actual, ( + f"Expected `{path.text}` to contain the key " + f"{argument!r}, but its keys are: " + + (', '.join(repr(key) for key in sorted(actual)) or "none") + ) + return + raise ValueError( + f"`{path.text}` is {actual!r}; containing needs a string, " + "list, or map" + ) + + +def _assert_of_length( + path: PropertyPath, + actual: JsonValue, + length: int, +) -> None: + """Asserts the length predicate on the property's actual value: + the length of a string, list, or map.""" + if not isinstance(actual, (str, list, dict)): + raise ValueError( + f"`{path.text}` is {actual!r}; of length needs a " + "string, list, or map" + ) + assert len(actual) == length, ( + f"Expected `{path.text}` to be of length {length}, but it " + f"is of length {len(actual)}: {actual!r}" + ) + + def _assert_properties( subject: Union[Message, Model], assertions: list[Assertion], ) -> None: - """Asserts that each of the given assertions holds on the given + """Asserts each of the given assertions against the given response or error, comparing under the subject type's semantics.""" subject_json = _json_object(subject) for assertion in assertions: actual = _resolve_json_property(subject_json, assertion.path) - if isinstance(subject, Message): - matches = _proto_property_matches( - type(subject), assertion.path, actual, assertion.value - ) - else: - matches = _pydantic_property_matches( - type(subject), assertion.path, actual, assertion.value - ) - assert matches, ( - f"Expected `{assertion.path.text}` to be " - f"{assertion.value!r}, but it is {actual!r}" - ) + match assertion: + case Equals(path=path, value=value): + assert _property_matches(subject, path, actual, value), ( + f"Expected `{path.text}` to be {value!r}, " + f"but it is {actual!r}" + ) + case Containing(path=path, value=value): + _assert_containing(subject, path, actual, value) + case OfLength(path=path, length=length): + _assert_of_length(path, actual, length) @given('the application is up') @@ -577,25 +824,19 @@ def _assert_aborted( clauses: Optional[str], ) -> None: """Asserts that the given abort's error is of the named type and - satisfies the given 'where' clauses.""" + satisfies the given 'with' clauses.""" error = aborted.error assert type(error).__name__ == error_type, ( f"Expected an abort with `{error_type}`, but it aborted " f"with `{type(error).__name__}`: {aborted}" ) - _assert_properties( - error, - [ - Equals(path=assignment.path, value=assignment.value) - for assignment in _parse_assignments(world, clauses) - ], - ) + _assert_properties(error, _parse_assertions(world, clauses)) @then( parsers.re( r'the attempt aborts with `(?P\w+)`' - rf'(?: where (?P{_PROPERTY_CLAUSES}))?$' + rf'(?: with (?P{_ASSERT_CLAUSES}))?$' ) ) def _the_attempt_aborts_with( @@ -641,7 +882,7 @@ async def _read( @then( parsers.re( rf'`(?P\w+)` on {_STATE} ' - rf'has (?P{_PROPERTY_CLAUSES})$' + rf'has (?P{_ASSERT_CLAUSES})$' ) ) async def _then_has( @@ -652,13 +893,7 @@ async def _then_has( clauses: str, ) -> None: response = await _read(world, method, state_type, state_id) - _assert_properties( - response, - [ - Equals(path=assignment.path, value=assignment.value) - for assignment in _parse_assignments(world, clauses) - ], - ) + _assert_properties(response, _parse_assertions(world, clauses)) @given( @@ -690,7 +925,7 @@ async def _has_saved_as( parsers.re( rf'`(?P\w+)` on {_STATE} ' r'aborts with `(?P\w+)`' - rf'(?: where (?P{_PROPERTY_CLAUSES}))?$' + rf'(?: with (?P{_ASSERT_CLAUSES}))?$' ) ) async def _aborts_with( @@ -724,19 +959,13 @@ async def _aborts_with( ) -@then(parsers.re(rf'the result has (?P{_PROPERTY_CLAUSES})$')) +@then(parsers.re(rf'the result has (?P{_ASSERT_CLAUSES})$')) def _the_result_has(world: World, clauses: str) -> None: assert world.response is not None, ( "Expected a preceding step to have made a call that returned " "a response, but there is none" ) - _assert_properties( - world.response, - [ - Equals(path=assignment.path, value=assignment.value) - for assignment in _parse_assignments(world, clauses) - ], - ) + _assert_properties(world.response, _parse_assertions(world, clauses)) @given( @@ -772,8 +1001,8 @@ def _the_resulting_property_is_saved_as( # step's tail never matches one of these. -@given(parsers.re(rf'.+ has {_PROPERTY_CLAUSES}$')) -@when(parsers.re(rf'.+ has {_PROPERTY_CLAUSES}$')) +@given(parsers.re(rf'.+ has {_ASSERT_CLAUSES}$')) +@when(parsers.re(rf'.+ has {_ASSERT_CLAUSES}$')) def _almost_asserting_under_given_or_when() -> None: raise ValueError( "Almost: a Given or When 'has' saves, e.g. `name` saved as " @@ -813,6 +1042,12 @@ def _almost_mixing_clauses() -> None: rf'(?:{_SEPARATOR}{_CLAUSE})*$' ) ) +@then( + parsers.re( + rf'.+ with (?=.*`\s+saved\s){_CLAUSE}' + rf'(?:{_SEPARATOR}{_CLAUSE})*$' + ) +) def _almost_saving_in_with() -> None: raise ValueError( "Almost: saving goes under a Given or When 'has', not a " @@ -820,16 +1055,22 @@ def _almost_saving_in_with() -> None: ) -@then( +@given( parsers.re( - rf'.+ where (?=.*`\s+saved\s){_CLAUSE}' - rf'(?:{_SEPARATOR}{_CLAUSE})*$' + rf'.+ with (?=.*`\s+contain|.*`\s+(?:of\s+)?length)' + rf'{_ASSERT_CLAUSES}$' ) ) -def _almost_saving_in_where() -> None: +@when( + parsers.re( + rf'.+ with (?=.*`\s+contain|.*`\s+(?:of\s+)?length)' + rf'{_ASSERT_CLAUSES}$' + ) +) +def _almost_predicate_in_call_with() -> None: raise ValueError( - "Almost: saving goes under a Given or When 'has', not a " - "'where' list" + "Almost: 'containing' and 'of length' assert; they go in a " + "Then 'has' or an abort's 'with', not a call's 'with'" ) @@ -841,9 +1082,9 @@ def _almost_saving_in_where() -> None: _UNCLOSED_CLAUSES = r'`[^`]*(?:`[^`]*`[^`]*)*' -@given(parsers.re(rf'.+ (?:with|has|where) {_UNBACKTICKED_CLAUSES}$')) -@when(parsers.re(rf'.+ (?:with|has|where) {_UNBACKTICKED_CLAUSES}$')) -@then(parsers.re(rf'.+ (?:with|has|where) {_UNBACKTICKED_CLAUSES}$')) +@given(parsers.re(rf'.+ (?:with|has) {_UNBACKTICKED_CLAUSES}$')) +@when(parsers.re(rf'.+ (?:with|has) {_UNBACKTICKED_CLAUSES}$')) +@then(parsers.re(rf'.+ (?:with|has) {_UNBACKTICKED_CLAUSES}$')) def _almost_missing_backticks() -> None: raise ValueError( "Almost: each clause goes in backticks, e.g. `amount=50` " @@ -851,8 +1092,8 @@ def _almost_missing_backticks() -> None: ) -@given(parsers.re(rf'.+ (?:with|has|where) {_UNCLOSED_CLAUSES}$')) -@when(parsers.re(rf'.+ (?:with|has|where) {_UNCLOSED_CLAUSES}$')) -@then(parsers.re(rf'.+ (?:with|has|where) {_UNCLOSED_CLAUSES}$')) +@given(parsers.re(rf'.+ (?:with|has) {_UNCLOSED_CLAUSES}$')) +@when(parsers.re(rf'.+ (?:with|has) {_UNCLOSED_CLAUSES}$')) +@then(parsers.re(rf'.+ (?:with|has) {_UNCLOSED_CLAUSES}$')) def _almost_unclosed_backtick() -> None: raise ValueError("Almost: a backtick is unclosed") diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 566fcc848..5246a7d88 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -13,7 +13,7 @@ Feature: Accounts Given an `Account` for "bob" gets created via `open` And the `Account` for "bob" gets a `deposit` with `amount=30` When the `Account` for "bob" attempts a `withdraw` with `amount=50` - Then the attempt aborts with `OverdraftError` where `amount=20` + Then the attempt aborts with `OverdraftError` with `amount=20` And `balance` on the `Account` for "bob" has `balance=30` Scenario: Custom async steps share the application @@ -45,6 +45,8 @@ Feature: Accounts Then `get_owner` on the `Account` for "frank" has `owner={name: "Frankie", tags: ["pro"]}` And `get_owner` on the `Account` for "frank" has `owner.name="Frankie"` And `get_owner` on the `Account` for "frank" has `owner.tags[0]="pro"` + And `get_owner` on the `Account` for "frank" has `owner.name` containing "rank" and `owner.tags` of length 1 + And `get_owner` on the `Account` for "frank" has `owner.tags` containing "pro" When `get_owner` on the `Account` for "frank" has `owner.name` saved as "$owner_name" And an `Account` for "$owner_name" gets created via `open` with `initial_balance=1` Then `balance` on the `Account` for "Frankie" has `balance=1` @@ -61,6 +63,7 @@ Feature: Accounts When the `Account` for "heidi" gets a `put_owner` with `key="main"` and `owner={name: "Heidi", tags: ["a"]}` Then `get_owners` on the `Account` for "heidi" has `owners["main"].name="Heidi"` And `get_owners` on the `Account` for "heidi" has `owners={main: {name: "Heidi", tags: ["a"]}}` + And `get_owners` on the `Account` for "heidi" has `owners` containing "main" and `owners` of length 1 Scenario: Steps can share one context Given a shared context diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index bb5f707d8..fc20d5d12 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -14,20 +14,24 @@ from reboot.bdd.fixtures import JsonValue, PropertyPath, World from reboot.bdd.steps import * from reboot.bdd.steps import ( + _ASSERT_CLAUSES, _MIXED_CLAUSES, _PROPERTY_CLAUSES, _SAVE_CLAUSES, Assertion, + Containing, Equals, + OfLength, _almost_asserting_under_given_or_when, _almost_missing_backticks, _almost_mixing_clauses, - _almost_saving_in_where, + _almost_predicate_in_call_with, _almost_saving_in_with, _almost_saving_under_then, _almost_unclosed_backtick, _assert_aborted, _assert_properties, + _parse_assertions, _parse_assignments, _parse_saves, ) @@ -75,6 +79,12 @@ def test_clause_grammar_routing() -> None: assert re.fullmatch(_MIXED_CLAUSES, mixed) assert not re.fullmatch(_MIXED_CLAUSES, properties) assert not re.fullmatch(_MIXED_CLAUSES, saves) + predicates = '`name` containing "a and b" and `tags` of length 2' + assert re.fullmatch(_ASSERT_CLAUSES, predicates) + assert re.fullmatch(_ASSERT_CLAUSES, properties) + assert not re.fullmatch(_ASSERT_CLAUSES, saves) + assert not re.fullmatch(_PROPERTY_CLAUSES, predicates) + assert not re.fullmatch(_SAVE_CLAUSES, predicates) # Lexical near-misses still route to their kind. assert re.fullmatch(_PROPERTY_CLAUSES, '`amount: 50`') assert re.fullmatch(_PROPERTY_CLAUSES, '`amount = 50`') @@ -114,15 +124,79 @@ def test_almost_steps_raise() -> None: _almost_mixing_clauses() with pytest.raises(ValueError, match="not a 'with' list"): _almost_saving_in_with() - with pytest.raises(ValueError, match="not a 'where' list"): - _almost_saving_in_where() + with pytest.raises(ValueError, match="not a call's 'with'"): + _almost_predicate_in_call_with() with pytest.raises(ValueError, match="goes in backticks"): _almost_missing_backticks() with pytest.raises(ValueError, match="backtick is unclosed"): _almost_unclosed_backtick() -def test_assert_aborted_where() -> None: +def test_parse_assertions() -> None: + world = World() + assert _parse_assertions( + world, + '`name` containing "a and b", `tags` of length 2, and ' + '`balance=50`', + ) == [ + Containing(path=PropertyPath.create('name'), value='a and b'), + OfLength(path=PropertyPath.create('tags'), length=2), + Equals(path=PropertyPath.create('balance'), value=50), + ] + with pytest.raises(ValueError, match="'containing', not 'contains'"): + _parse_assertions(world, '`name` contains "a"') + with pytest.raises(ValueError, match="'of length', not 'length'"): + _parse_assertions(world, '`tags` length 2') + with pytest.raises(ValueError, match="takes a whole number"): + _parse_assertions(world, '`tags` of length "2"') + + +def test_assert_predicates() -> None: + tagged = GetOwnerResponse(owner=Owner(name='Frank', tags=['vip', 'beta'])) + _assert_properties( + tagged, + [Containing(path=PropertyPath.create('owner.name'), value='ran')], + ) + _assert_properties( + tagged, + [Containing(path=PropertyPath.create('owner.tags'), value='vip')], + ) + _assert_properties( + tagged, + [OfLength(path=PropertyPath.create('owner.tags'), length=2)], + ) + with pytest.raises(AssertionError): + _assert_properties( + tagged, + [Containing(path=PropertyPath.create('owner.name'), value='z')], + ) + with pytest.raises(AssertionError): + _assert_properties( + tagged, + [Containing(path=PropertyPath.create('owner.tags'), value='x')], + ) + with pytest.raises(AssertionError): + _assert_properties( + tagged, + [OfLength(path=PropertyPath.create('owner.tags'), length=3)], + ) + with pytest.raises(ValueError, match="takes a string"): + _assert_properties( + tagged, + [Containing(path=PropertyPath.create('owner.name'), value=5)], + ) + _assert_properties( + tagged, + [Containing(path=PropertyPath.create('owner'), value='name')], + ) + with pytest.raises(ValueError, match="takes a string key"): + _assert_properties( + tagged, + [Containing(path=PropertyPath.create('owner'), value=5)], + ) + + +def test_assert_aborted_with() -> None: world = World() aborted = Account.WithdrawAborted(OverdraftError(amount=20)) _assert_aborted(world, aborted, 'OverdraftError', '`amount=20`') diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index b8c213230..0317e793d 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -13,7 +13,7 @@ Feature: Accounts with a pydantic API Given an `Account` for "bob" gets created via `open` And the `Account` for "bob" gets a `deposit` with `amount=30` When the `Account` for "bob" attempts a `withdraw` with `amount=50` - Then the attempt aborts with `OverdraftError` where `amount=20` + Then the attempt aborts with `OverdraftError` with `amount=20` And `balance` on the `Account` for "bob" has `balance=30` Scenario: Custom async steps can call through the world @@ -28,6 +28,8 @@ Feature: Accounts with a pydantic API When the `Account` for "frank" gets a `set_owner` with `owner.name="Frankie"` and `owner.tags=["pro"]` Then `get_owner` on the `Account` for "frank" has `owner={name: "Frankie", tags: ["pro"]}` And `get_owner` on the `Account` for "frank" has `owner.tags[0]="pro"` + And `get_owner` on the `Account` for "frank" has `owner.name` containing "rank" and `owner.tags` of length 1 + And `get_owner` on the `Account` for "frank" has `owner.tags` containing "pro" When `get_owner` on the `Account` for "frank" has `owner` saved as "$owner" And an `Account` for "franklin" gets created via `open` And the `Account` for "franklin" gets a `set_owner` with `owner=$owner` @@ -38,3 +40,4 @@ Feature: Accounts with a pydantic API When the `Account` for "heidi" gets a `put_owner` with `key="main"` and `owner={name: "Heidi", tags: ["a"]}` Then `get_owners` on the `Account` for "heidi" has `owners["main"].name="Heidi"` And `get_owners` on the `Account` for "heidi" has `owners={main: {name: "Heidi", tags: ["a"]}}` + And `get_owners` on the `Account` for "heidi" has `owners` containing "main" and `owners` of length 1 diff --git a/tests/reboot/bdd/pydantic/bdd_tests.py b/tests/reboot/bdd/pydantic/bdd_tests.py index 9f6b77989..d4f94cfb0 100644 --- a/tests/reboot/bdd/pydantic/bdd_tests.py +++ b/tests/reboot/bdd/pydantic/bdd_tests.py @@ -12,8 +12,15 @@ from reboot.bdd import when from reboot.bdd.fixtures import Assignment, JsonValue, PropertyPath, World from reboot.bdd.steps import * -from reboot.bdd.steps import Assertion, Equals, _assert_properties +from reboot.bdd.steps import ( + Assertion, + Containing, + Equals, + OfLength, + _assert_properties, +) from tests.reboot.bdd.pydantic.account_api import ( + DepositResponse, GetOwnerResponse, GetOwnersResponse, Owner, @@ -139,6 +146,31 @@ def _assertions( ] +def test_assert_predicates_pydantic_semantics() -> None: + owners = GetOwnersResponse(owners={'main': Owner(name='Heidi')}) + _assert_properties( + owners, + [Containing(path=PropertyPath.create('owners'), value='main')], + ) + _assert_properties( + owners, + [OfLength(path=PropertyPath.create('owners'), length=1)], + ) + with pytest.raises(AssertionError): + _assert_properties( + owners, + [Containing(path=PropertyPath.create('owners'), value='other')], + ) + # A pydantic number is a number in JSON, so neither predicate + # applies to it. + response = DepositResponse(updated_balance=150) + with pytest.raises(ValueError, match="needs a string, list, or map"): + _assert_properties( + response, + [OfLength(path=PropertyPath.create('updated_balance'), length=3)], + ) + + def test_assert_properties_pydantic_semantics() -> None: response = GetOwnerResponse(owner=Owner(name='Frank')) _assert_properties(response, _assertions({'owner': {'name': 'Frank'}})) From 857bd595e8f516f8e99964095e788b248793d1ed Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 19:18:35 +0000 Subject: [PATCH 08/42] Let `reboot.bdd` scenarios say who they call as '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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/fixtures.py | 18 ++++++- reboot/bdd/steps.py | 53 ++++++++++++++----- tests/reboot/bdd/account.proto | 12 +++++ tests/reboot/bdd/account_servicer.py | 14 +++++ tests/reboot/bdd/accounts.feature | 7 +++ tests/reboot/bdd/bdd_tests.py | 21 ++++++++ tests/reboot/bdd/pydantic/account_api.py | 10 ++++ tests/reboot/bdd/pydantic/account_servicer.py | 12 +++++ tests/reboot/bdd/pydantic/accounts.feature | 7 +++ 9 files changed, 140 insertions(+), 14 deletions(-) diff --git a/reboot/bdd/fixtures.py b/reboot/bdd/fixtures.py index f24617e49..c37b6d8a4 100644 --- a/reboot/bdd/fixtures.py +++ b/reboot/bdd/fixtures.py @@ -159,6 +159,10 @@ class World: # or `None` if that call succeeded. aborted: Optional[Aborted] = None + # The bearer token every context created from here on carries; + # `None` calls anonymously. + bearer_token: Optional[str] = None + def context(self) -> ExternalContext: """The context for one step's call: the scenario's shared context once a 'Given a shared context' step has created it, @@ -172,9 +176,21 @@ def context(self) -> ExternalContext: ) self.contexts_created += 1 return self.rbt.create_external_context( - name=f"{self.name}-{self.contexts_created}" + name=f"{self.name}-{self.contexts_created}", + bearer_token=self.bearer_token, ) + def set_bearer_token(self, bearer_token: str) -> None: + """Sets the bearer token every context created from here on + carries; raises once a shared context exists, which keeps the + token it was created with.""" + if self.shared_context is not None: + raise ValueError( + "The shared context already carries an identity; say " + "who you are before 'Given a shared context'" + ) + self.bearer_token = bearer_token + def client_type(self, state_type: str) -> Any: """The generated client class of the named state type, named by its full state type name (e.g. 'bank.v1.Account') or, when diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 1dedcb056..c82db76b1 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -30,6 +30,12 @@ def application() -> Application: Then `balance` on the `Account` for "alice" has `balance=50` +A scenario says who it calls as with 'Given I am "alice"', which +mints a test token for that user ID and puts it on every context +created from then on ('the bearer token is "..." ' instead sets a +raw token); say who you are before 'Given a shared context', whose +context keeps the token it was created with. + A Then 'has' asserts and a Given or When 'has' saves, and readers are only read that way: 'gets a' and 'attempts a' refuse readers the way 'has' refuses writers, and a reader's abort is asserted with @@ -221,16 +227,16 @@ def _saved_value(world: World, name: str) -> JsonValue: return world.saved[name] -def _resolve_state_id(world: World, state_id: str) -> str: - """The state ID a step names: the saved value when the ID is of - the form '$name', otherwise the ID itself.""" - if not re.fullmatch(r'\$\w+', state_id): - return state_id - value = _saved_value(world, state_id[1:]) +def _maybe_saved(world: World, text: str) -> str: + """The saved value the text names when it is of the form + '$name', which must be a string, otherwise the text itself.""" + if not re.fullmatch(r'\$\w+', text): + return text + value = _saved_value(world, text[1:]) if not isinstance(value, str): raise ValueError( - f'The value saved as "${state_id[1:]}" must be a string ' - f"to name a state, but it is {value!r}" + f'Expecting the value saved as "${text[1:]}" to be a ' + f"string, but it is {value!r}" ) return value @@ -719,6 +725,27 @@ async def _the_application_is_up( world.name = request.node.name +@given(parsers.re(r'I am "(?P[^"]*)"$')) +@when(parsers.re(r'I am "(?P[^"]*)"$')) +async def _i_am(world: World, user_id: str) -> None: + if world.rbt is None: + raise ValueError( + "The application is not up; start the scenario with " + "'Given the application is up'" + ) + world.set_bearer_token( + await world.rbt.make_valid_oauth_access_token( + user_id=_maybe_saved(world, user_id), + ) + ) + + +@given(parsers.re(r'the bearer token is "(?P[^"]*)"$')) +@when(parsers.re(r'the bearer token is "(?P[^"]*)"$')) +def _the_bearer_token_is(world: World, bearer_token: str) -> None: + world.set_bearer_token(_maybe_saved(world, bearer_token)) + + @given('a shared context') def _a_shared_context(world: World) -> None: world.shared_context = world.context() @@ -745,7 +772,7 @@ async def _gets_created_via( ) -> None: factory = world.factory(state_type=state_type, method=method) assignments = _parse_assignments(world, clauses) - arguments = [world.context(), _resolve_state_id(world, state_id)] + arguments = [world.context(), _maybe_saved(world, state_id)] if assignments: arguments.append( world.request( @@ -778,7 +805,7 @@ async def _gets_a( try: world.response = await world.call( state_type=state_type, - state_id=_resolve_state_id(world, state_id), + state_id=_maybe_saved(world, state_id), method=method, assignments=_parse_assignments(world, clauses), ) @@ -808,7 +835,7 @@ async def _attempts_a( try: world.response = await world.call( state_type=state_type, - state_id=_resolve_state_id(world, state_id), + state_id=_maybe_saved(world, state_id), method=method, assignments=_parse_assignments(world, clauses), ) @@ -867,7 +894,7 @@ async def _read( try: world.response = await world.call( state_type=state_type, - state_id=_resolve_state_id(world, state_id), + state_id=_maybe_saved(world, state_id), method=method, assignments={}, ) @@ -945,7 +972,7 @@ async def _aborts_with( try: await world.call( state_type=state_type, - state_id=_resolve_state_id(world, state_id), + state_id=_maybe_saved(world, state_id), method=method, assignments={}, ) diff --git a/tests/reboot/bdd/account.proto b/tests/reboot/bdd/account.proto index 4458e1998..9d10384a3 100644 --- a/tests/reboot/bdd/account.proto +++ b/tests/reboot/bdd/account.proto @@ -31,6 +31,11 @@ service AccountMethods { }; } + rpc Whoami(WhoamiRequest) returns (WhoamiResponse) { + option (rbt.v1alpha1.method).reader = { + }; + } + rpc SetOwner(SetOwnerRequest) returns (SetOwnerResponse) { option (rbt.v1alpha1.method).writer = { }; @@ -75,6 +80,13 @@ message OpenResponse { message BalanceRequest {} +message WhoamiRequest {} + +message WhoamiResponse { + // ID of the authenticated caller; empty when anonymous. + string user_id = 1; +} + message SetOwnerRequest { Owner owner = 1; diff --git a/tests/reboot/bdd/account_servicer.py b/tests/reboot/bdd/account_servicer.py index af60445da..bdd61ee5a 100644 --- a/tests/reboot/bdd/account_servicer.py +++ b/tests/reboot/bdd/account_servicer.py @@ -19,6 +19,8 @@ PutOwnerResponse, SetOwnerRequest, SetOwnerResponse, + WhoamiRequest, + WhoamiResponse, WithdrawRequest, WithdrawResponse, ) @@ -44,6 +46,18 @@ async def balance( ) -> BalanceResponse: return BalanceResponse(balance=self.state.balance) + async def whoami( + self, + context: ReaderContext, + request: WhoamiRequest, + ) -> WhoamiResponse: + return WhoamiResponse( + user_id=( + context.auth.user_id if context.auth is not None and + context.auth.user_id is not None else "" + ) + ) + async def set_owner( self, context: WriterContext, diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 5246a7d88..0b30627c0 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -70,3 +70,10 @@ Feature: Accounts And an `Account` for "dave" gets created via `open` When the `Account` for "dave" gets a `deposit` with `amount=5` Then `balance` on the `Account` for "dave" has `balance=5` + + Scenario: Steps call as who I am + Given I am "alice" + And an `Account` for "joint" gets created via `open` + Then `whoami` on the `Account` for "joint" has `user_id="alice"` + When I am "bob" + Then `whoami` on the `Account` for "joint" has `user_id="bob"` diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index fc20d5d12..3ca85534a 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -10,6 +10,7 @@ import pytest import re from pytest_bdd import parsers, scenarios +from reboot.aio.external import ExternalContext from reboot.bdd import when from reboot.bdd.fixtures import JsonValue, PropertyPath, World from reboot.bdd.steps import * @@ -34,6 +35,7 @@ _parse_assertions, _parse_assignments, _parse_saves, + _the_bearer_token_is, ) from tests.reboot.bdd.account_pb2 import ( BalanceResponse, @@ -43,6 +45,7 @@ Owner, ) from tests.reboot.bdd.account_rbt import Account +from typing import cast # A custom `async def` step, the way a developer would write one: it @@ -66,6 +69,24 @@ def test_is_reader() -> None: assert not world.is_reader(state_type='Account', method='deposit') +def test_the_bearer_token_is() -> None: + world = World() + _the_bearer_token_is(world, 'admin-key') + assert world.bearer_token == 'admin-key' + world.saved['token'] = 'saved-key' + _the_bearer_token_is(world, '$token') + assert world.bearer_token == 'saved-key' + + +def test_set_bearer_token_guard() -> None: + world = World() + world.set_bearer_token('token') + assert world.bearer_token == 'token' + world.shared_context = cast(ExternalContext, object()) + with pytest.raises(ValueError, match="before 'Given a shared context'"): + world.set_bearer_token('other') + + def test_clause_grammar_routing() -> None: properties = '`balance=50` and `owner.name="F"`' saves = '`balance` saved as "$b", and `owner` saved as "$o"' diff --git a/tests/reboot/bdd/pydantic/account_api.py b/tests/reboot/bdd/pydantic/account_api.py index d44418a04..76900206f 100644 --- a/tests/reboot/bdd/pydantic/account_api.py +++ b/tests/reboot/bdd/pydantic/account_api.py @@ -40,6 +40,11 @@ class BalanceResponse(Model): balance: int = Field(tag=1) +class WhoamiResponse(Model): + # ID of the authenticated caller; empty when anonymous. + user_id: str = Field(tag=1, default='') + + class SetOwnerRequest(Model): owner: Owner = Field(tag=1) # Owners in addition to `owner`. @@ -87,6 +92,11 @@ class OverdraftError(Model): response=BalanceResponse, mcp=None, ), + whoami=Reader( + request=None, + response=WhoamiResponse, + mcp=None, + ), set_owner=Writer( request=SetOwnerRequest, response=None, diff --git a/tests/reboot/bdd/pydantic/account_servicer.py b/tests/reboot/bdd/pydantic/account_servicer.py index 3484c4667..80abb8772 100644 --- a/tests/reboot/bdd/pydantic/account_servicer.py +++ b/tests/reboot/bdd/pydantic/account_servicer.py @@ -13,6 +13,7 @@ OverdraftError, PutOwnerRequest, SetOwnerRequest, + WhoamiResponse, WithdrawRequest, WithdrawResponse, ) @@ -58,6 +59,17 @@ async def balance( ) -> BalanceResponse: return BalanceResponse(balance=self.state.balance) + async def whoami( + self, + context: ReaderContext, + ) -> WhoamiResponse: + return WhoamiResponse( + user_id=( + context.auth.user_id if context.auth is not None and + context.auth.user_id is not None else '' + ) + ) + async def set_owner( self, context: WriterContext, diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index 0317e793d..a8fc47bfa 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -41,3 +41,10 @@ Feature: Accounts with a pydantic API Then `get_owners` on the `Account` for "heidi" has `owners["main"].name="Heidi"` And `get_owners` on the `Account` for "heidi" has `owners={main: {name: "Heidi", tags: ["a"]}}` And `get_owners` on the `Account` for "heidi" has `owners` containing "main" and `owners` of length 1 + + Scenario: Steps call as who I am + Given I am "alice" + And an `Account` for "joint" gets created via `open` + Then `whoami` on the `Account` for "joint" has `user_id="alice"` + When I am "bob" + Then `whoami` on the `Account` for "joint" has `user_id="bob"` From 154a1397fbd4459eced6946ff42dc4adf4ddf321 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 20:02:31 +0000 Subject: [PATCH 09/42] Let `reboot.bdd` assertions wait with `eventually has` '`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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/steps.py | 114 ++++++++++++++++++ tests/reboot/bdd/account.proto | 12 ++ tests/reboot/bdd/account_servicer.py | 10 ++ tests/reboot/bdd/accounts.feature | 5 + tests/reboot/bdd/bdd_tests.py | 12 ++ tests/reboot/bdd/pydantic/account_api.py | 10 ++ tests/reboot/bdd/pydantic/account_servicer.py | 8 ++ tests/reboot/bdd/pydantic/accounts.feature | 5 + 8 files changed, 176 insertions(+) diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index c82db76b1..a6369876b 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -36,6 +36,12 @@ def application() -> Application: raw token); say who you are before 'Given a shared context', whose context keeps the token it was created with. +A Then 'eventually has' holds a reactive read open until its +assertions hold, waiting at most its required bound, e.g.: + + Then `balance` on the `Account` for "alice" eventually has + `balance=150` within 30 seconds + A Then 'has' asserts and a Given or When 'has' saves, and readers are only read that way: 'gets a' and 'attempts a' refuse readers the way 'has' refuses writers, and a reader's abort is asserted with @@ -61,6 +67,7 @@ def application() -> Application: # # ruff: noqa: F811 +import asyncio import json5 import jsonpath_ng import pytest @@ -283,6 +290,20 @@ def _almost_length_message(clause: str) -> str: ) +def _almost_within_message(within: str) -> str: + """The 'Almost' error for a wait bound that is a lexical + near-miss of within seconds.""" + if re.fullmatch(r'\d+(?:\.\d+)?\s*s', within): + return ( + "Almost: say seconds, e.g. within 10 seconds: within " + f"{within}" + ) + return ( + "Expected a wait bound of the form within 10 seconds, but " + f"got: within {within}" + ) + + def _almost_save_message(clause: str) -> str: """The 'Almost' error for a saving clause that is a lexical near-miss of `name` saved as "$name".""" @@ -923,6 +944,74 @@ async def _then_has( _assert_properties(response, _parse_assertions(world, clauses)) +@then( + parsers.re( + rf'`(?P\w+)` on {_STATE} ' + rf'eventually has (?P{_ASSERT_CLAUSES}) ' + r'within (?P.+)$' + ) +) +async def _eventually_has( + world: World, + method: str, + state_type: str, + state_id: str, + clauses: str, + within: str, +) -> None: + seconds_match = re.fullmatch(r'(\d+(?:\.\d+)?) seconds?', within) + if seconds_match is None: + raise ValueError(_almost_within_message(within)) + seconds = float(seconds_match[1]) + assertions = _parse_assertions(world, clauses) + if not world.is_reader(state_type=state_type, method=method): + raise ValueError( + f"`{method}` is not a reader; 'eventually has' holds a " + "reactive read open, which only readers serve" + ) + reference = world.client_type(state_type).ref( + _maybe_saved(world, state_id) + ) + responses = getattr(reference.reactively(), method)(world.context()) + deadline = asyncio.get_running_loop().time() + seconds + last_error: Optional[AssertionError] = None + try: + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise AssertionError( + f"Waited {within} for `{method}` on the " + f"`{state_type}` for \"{state_id}\", but " + ( + str(last_error) + if last_error is not None else "no response arrived" + ) + ) + try: + response = await asyncio.wait_for( + anext(responses), timeout=remaining + ) + except asyncio.TimeoutError: + continue + except StopAsyncIteration: + raise AssertionError( + f"The reactive read of `{method}` on the " + f"`{state_type}` for \"{state_id}\" ended, and " + ( + str(last_error) + if last_error is not None else "no response arrived" + ) + ) from None + else: + try: + _assert_properties(response, assertions) + except AssertionError as error: + last_error = error + continue + world.response = response + return + finally: + await responses.aclose() + + @given( parsers.re( rf'`(?P\w+)` on {_STATE} ' @@ -1028,6 +1117,31 @@ def _the_resulting_property_is_saved_as( # step's tail never matches one of these. +@then(parsers.re(rf'.+ eventually has {_ASSERT_CLAUSES}$')) +def _almost_eventually_needs_within() -> None: + raise ValueError( + "Almost: say how long 'eventually has' keeps its reactive " + "read open, e.g. within 10 seconds" + ) + + +@then(parsers.re(rf'.+(? None: + raise ValueError( + "Almost: 'within' goes with 'eventually has'; a plain 'has' " + "asserts the response it reads" + ) + + +@given(parsers.re(r'.+ eventually has .+$')) +@when(parsers.re(r'.+ eventually has .+$')) +def _almost_eventually_under_given_or_when() -> None: + raise ValueError( + "Almost: a Given or When 'has' saves what it reads now; " + "'eventually has' asserts, under a Then" + ) + + @given(parsers.re(rf'.+ has {_ASSERT_CLAUSES}$')) @when(parsers.re(rf'.+ has {_ASSERT_CLAUSES}$')) def _almost_asserting_under_given_or_when() -> None: diff --git a/tests/reboot/bdd/account.proto b/tests/reboot/bdd/account.proto index 9d10384a3..91c42269a 100644 --- a/tests/reboot/bdd/account.proto +++ b/tests/reboot/bdd/account.proto @@ -31,6 +31,11 @@ service AccountMethods { }; } + rpc DepositLater(DepositLaterRequest) returns (DepositLaterResponse) { + option (rbt.v1alpha1.method).writer = { + }; + } + rpc Whoami(WhoamiRequest) returns (WhoamiResponse) { option (rbt.v1alpha1.method).reader = { }; @@ -80,6 +85,13 @@ message OpenResponse { message BalanceRequest {} +message DepositLaterRequest { + // Amount a scheduled task will deposit. + int64 amount = 1; +} + +message DepositLaterResponse {} + message WhoamiRequest {} message WhoamiResponse { diff --git a/tests/reboot/bdd/account_servicer.py b/tests/reboot/bdd/account_servicer.py index bdd61ee5a..e8766ae99 100644 --- a/tests/reboot/bdd/account_servicer.py +++ b/tests/reboot/bdd/account_servicer.py @@ -7,6 +7,8 @@ Account, BalanceRequest, BalanceResponse, + DepositLaterRequest, + DepositLaterResponse, DepositRequest, DepositResponse, GetOwnerRequest, @@ -46,6 +48,14 @@ async def balance( ) -> BalanceResponse: return BalanceResponse(balance=self.state.balance) + async def deposit_later( + self, + context: WriterContext, + request: DepositLaterRequest, + ) -> DepositLaterResponse: + await self.ref().schedule().deposit(context, amount=request.amount) + return DepositLaterResponse() + async def whoami( self, context: ReaderContext, diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 0b30627c0..29dc14026 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -77,3 +77,8 @@ Feature: Accounts Then `whoami` on the `Account` for "joint" has `user_id="alice"` When I am "bob" Then `whoami` on the `Account` for "joint" has `user_id="bob"` + + Scenario: Effects land eventually + Given an `Account` for "slow" gets created via `open` + When the `Account` for "slow" gets a `deposit_later` with `amount=75` + Then `balance` on the `Account` for "slow" eventually has `balance=75` within 30 seconds diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index 3ca85534a..1a9f0b935 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -24,12 +24,16 @@ Equals, OfLength, _almost_asserting_under_given_or_when, + _almost_eventually_needs_within, + _almost_eventually_under_given_or_when, _almost_missing_backticks, _almost_mixing_clauses, _almost_predicate_in_call_with, _almost_saving_in_with, _almost_saving_under_then, _almost_unclosed_backtick, + _almost_within_message, + _almost_within_needs_eventually, _assert_aborted, _assert_properties, _parse_assertions, @@ -147,6 +151,14 @@ def test_almost_steps_raise() -> None: _almost_saving_in_with() with pytest.raises(ValueError, match="not a call's 'with'"): _almost_predicate_in_call_with() + with pytest.raises(ValueError, match="say how long"): + _almost_eventually_needs_within() + with pytest.raises(ValueError, match="goes with 'eventually has'"): + _almost_within_needs_eventually() + with pytest.raises(ValueError, match="asserts, under a Then"): + _almost_eventually_under_given_or_when() + assert "say seconds" in _almost_within_message('10s') + assert "within 10 seconds" in _almost_within_message('ten seconds') with pytest.raises(ValueError, match="goes in backticks"): _almost_missing_backticks() with pytest.raises(ValueError, match="backtick is unclosed"): diff --git a/tests/reboot/bdd/pydantic/account_api.py b/tests/reboot/bdd/pydantic/account_api.py index 76900206f..286c88f23 100644 --- a/tests/reboot/bdd/pydantic/account_api.py +++ b/tests/reboot/bdd/pydantic/account_api.py @@ -28,6 +28,11 @@ class DepositResponse(Model): updated_balance: int = Field(tag=1) +class DepositLaterRequest(Model): + # Amount a scheduled task will deposit. + amount: int = Field(tag=1) + + class WithdrawRequest(Model): amount: int = Field(tag=1) @@ -81,6 +86,11 @@ class OverdraftError(Model): response=DepositResponse, mcp=None, ), + deposit_later=Writer( + request=DepositLaterRequest, + response=None, + mcp=None, + ), withdraw=Writer( request=WithdrawRequest, response=WithdrawResponse, diff --git a/tests/reboot/bdd/pydantic/account_servicer.py b/tests/reboot/bdd/pydantic/account_servicer.py index 80abb8772..3bff69200 100644 --- a/tests/reboot/bdd/pydantic/account_servicer.py +++ b/tests/reboot/bdd/pydantic/account_servicer.py @@ -5,6 +5,7 @@ from reboot.aio.contexts import ReaderContext, WriterContext from tests.reboot.bdd.pydantic.account_api import ( BalanceResponse, + DepositLaterRequest, DepositRequest, DepositResponse, GetOwnerResponse, @@ -59,6 +60,13 @@ async def balance( ) -> BalanceResponse: return BalanceResponse(balance=self.state.balance) + async def deposit_later( + self, + context: WriterContext, + request: DepositLaterRequest, + ) -> None: + await self.ref().schedule().deposit(context, amount=request.amount) + async def whoami( self, context: ReaderContext, diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index a8fc47bfa..38db6c942 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -48,3 +48,8 @@ Feature: Accounts with a pydantic API Then `whoami` on the `Account` for "joint" has `user_id="alice"` When I am "bob" Then `whoami` on the `Account` for "joint" has `user_id="bob"` + + Scenario: Effects land eventually + Given an `Account` for "slow" gets created via `open` + When the `Account` for "slow" gets a `deposit_later` with `amount=75` + Then `balance` on the `Account` for "slow" eventually has `balance=75` within 30 seconds From b67fdcc5a37683a8846eadb941e705ab9d1e4696 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 20:11:26 +0000 Subject: [PATCH 10/42] Spell `reboot.bdd` saves as `name` and recalls as ${name} 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/steps.py | 114 +++++++++++++-------- tests/reboot/bdd/accounts.feature | 26 ++--- tests/reboot/bdd/bdd_tests.py | 23 +++-- tests/reboot/bdd/pydantic/accounts.feature | 4 +- 4 files changed, 101 insertions(+), 66 deletions(-) diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index a6369876b..2de3dd6ad 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -50,14 +50,14 @@ def application() -> Application: An asserting list can also say the predicates `path` containing (a substring of a string, an element of a list, or a key of a map) and `path` of length . A Given or When 'has' instead -saves a property under a name, which later steps say as `$name`, in -a state's ID or as a property value (a quoted "$name" stays the -literal string): +saves a property under a backticked name, which later steps recall +as `${name}`, in a state's ID, a user's ID, a bearer token, or a +property value (a quoted "${name}" stays the literal string): When `get_owner` on the `Account` for "frank" has - `owner.name` saved as "$owner_name" - And the resulting `updated_balance` is saved as "$balance" - And the `Account` for "$owner_name" gets a `deposit` with + `owner.name` saved as `owner_name` + And the resulting `updated_balance` is saved as `balance` + And the `Account` for "${owner_name}" gets a `deposit` with `amount=1` """ @@ -112,18 +112,19 @@ def application() -> Application: _PROPERTY_CLAUSE = rf'`{_PATH}\s*[:=]\s*[^`]*`' _PROPERTY_PATTERN = re.compile(rf'`(?P{_PATH})=(?P\S[^`]*)`') -# One saving clause: the (possibly dotted) property name in -# backticks, saved under a '$name'. The groupless form embeds in -# step patterns and deliberately also matches lexical near-misses -# ('saved to', a missing '$' or missing quotes) so that those route -# to a step whose parser raises the fix; the compiled form is the -# strict shape, for extraction. -_SAVE_CLAUSE = rf'`{_PATH}`\s+saved\s+(?:as|to)\s+"?\$?\w+"?' -_SAVE_PATTERN = re.compile(rf'`(?P{_PATH})` saved as "\$(?P\w+)"') +# One saving clause: the (possibly dotted) property path in +# backticks, saved under a backticked name. The groupless form +# embeds in step patterns and deliberately also matches lexical +# near-misses ('saved to', a quoted or '$'-prefixed name) so that +# those route to a step whose parser raises the fix; the compiled +# form is the strict shape, for extraction. +_SAVE_CLAUSE = rf'`{_PATH}`\s+saved\s+(?:as|to)\s+(?:`\w+`|"?\$?\w+"?)' +_SAVE_PATTERN = re.compile(rf'`(?P{_PATH})` saved as `(?P\w+)`') # A predicate clause's argument: a scalar JSON value (a quoted -# string may contain separators), or a '$name' recall. -_ARGUMENT = r'(?:"(?:[^"\\]|\\.)*"|\$\w+|[-+.\w]+)' +# string may contain separators), or a '${name}' recall (a bare +# '$name' also matches, so its near-miss routes to the fix). +_ARGUMENT = r'(?:"(?:[^"\\]|\\.)*"|\$\{\w+\}|\$?[-+.\w]+)' # One containing clause: asserts a substring of a string, an element # of a list, or a key of a map. The groupless form embeds in step @@ -228,21 +229,25 @@ def _saved_value(world: World, name: str) -> JsonValue: none.""" if name not in world.saved: raise ValueError( - f'Nothing saved as "${name}"; saved: ' + - (', '.join(f'"${n}"' for n in sorted(world.saved)) or "nothing") + f"Nothing saved as `{name}`; saved: " + + (', '.join(f'`{n}`' for n in sorted(world.saved)) or "nothing") ) return world.saved[name] def _maybe_saved(world: World, text: str) -> str: """The saved value the text names when it is of the form - '$name', which must be a string, otherwise the text itself.""" - if not re.fullmatch(r'\$\w+', text): + '${name}', which must be a string, otherwise the text itself.""" + if re.fullmatch(r'\$\w+', text): + raise ValueError( + f"Almost: recall a save as ${{{text[1:]}}}, not {text}" + ) + if not re.fullmatch(r'\$\{\w+\}', text): return text - value = _saved_value(world, text[1:]) + value = _saved_value(world, text[2:-1]) if not isinstance(value, str): raise ValueError( - f'Expecting the value saved as "${text[1:]}" to be a ' + f"Expecting the value saved as `{text[2:-1]}` to be a " f"string, but it is {value!r}" ) return value @@ -306,19 +311,27 @@ def _almost_within_message(within: str) -> str: def _almost_save_message(clause: str) -> str: """The 'Almost' error for a saving clause that is a lexical - near-miss of `name` saved as "$name".""" + near-miss of `path` saved as `name`.""" if re.search(r'\bsaved\s+to\b', clause): return f"Almost: say 'saved as', not 'saved to': {clause}" - if re.search(r'\bsaved\s+as\s+\$\w+$', clause): - return f'Almost: quote the name, e.g. saved as "$name": {clause}' + if re.search(r'\bsaved\s+as\s+"?\$\w+"?$', clause): + return ( + "Almost: drop the '$' and say the name in backticks, " + f"e.g. saved as `name`: {clause}" + ) if re.search(r'\bsaved\s+as\s+"\w+"$', clause): return ( - "Almost: the name needs a '$', e.g. saved as " - f'"$name": {clause}' + "Almost: the name goes in backticks, not quotes, e.g. " + f"saved as `name`: {clause}" + ) + if re.search(r'\bsaved\s+as\s+\w+$', clause): + return ( + "Almost: the name goes in backticks, e.g. saved as " + f"`name`: {clause}" ) return ( - 'Expected a saving clause of the form `name` saved as "$name", ' - f'but got: {clause}' + "Expected a saving clause of the form `path` saved as " + f"`name`, but got: {clause}" ) @@ -328,7 +341,8 @@ def _parse_assignments( ) -> list[Assignment]: """Parses a call's 'with' list, e.g. '`amount=50` and `reason="promo"`', into `Assignment`s; a property value of the - form '$name' becomes the saved value going by that name. The step + form '${name}' becomes the saved value going by that name. The + step patterns admit lexical near-misses of a clause, so each clause is confirmed strict here, raising the fix.""" assignments: list[Assignment] = [] @@ -339,7 +353,13 @@ def _parse_assignments( if property_match is None: raise ValueError(_almost_property_message(clause_match[0])) if re.fullmatch(r'\$\w+', property_match['value']): - value = _saved_value(world, property_match['value'][1:]) + raise ValueError( + "Almost: recall a save as " + f"${{{property_match['value'][1:]}}}, not " + f"{property_match['value']}" + ) + if re.fullmatch(r'\$\{\w+\}', property_match['value']): + value = _saved_value(world, property_match['value'][2:-1]) else: try: value = json5.loads(property_match['value']) @@ -360,10 +380,15 @@ def _parse_assignments( def _parsed_argument(world: World, argument: str) -> JsonValue: - """The JSON value a predicate clause's argument says; a '$name' - becomes the saved value going by that name.""" + """The JSON value a predicate clause's argument says; a + '${name}' becomes the saved value going by that name.""" if re.fullmatch(r'\$\w+', argument): - return _saved_value(world, argument[1:]) + raise ValueError( + f"Almost: recall a save as ${{{argument[1:]}}}, not " + f"{argument}" + ) + if re.fullmatch(r'\$\{\w+\}', argument): + return _saved_value(world, argument[2:-1]) try: return json5.loads(argument) except ValueError as error: @@ -415,7 +440,13 @@ def _parse_assertions( if property_match is None: raise ValueError(_almost_property_message(clause)) if re.fullmatch(r'\$\w+', property_match['value']): - value = _saved_value(world, property_match['value'][1:]) + raise ValueError( + "Almost: recall a save as " + f"${{{property_match['value'][1:]}}}, not " + f"{property_match['value']}" + ) + if re.fullmatch(r'\$\{\w+\}', property_match['value']): + value = _saved_value(world, property_match['value'][2:-1]) else: try: value = json5.loads(property_match['value']) @@ -437,7 +468,7 @@ def _parse_assertions( def _parse_saves(clauses: str) -> dict[str, PropertyPath]: """Parses a Given or When 'has' list of saving clauses, e.g. - '`amount` saved as "$amount"', into the property to save under + '`amount` saved as `amount`', into the property to save under each name. The step patterns admit lexical near-misses of a clause, so each clause is confirmed strict here, raising the fix.""" @@ -1087,13 +1118,13 @@ def _the_result_has(world: World, clauses: str) -> None: @given( parsers.re( rf'the resulting `(?P{_PATH})` ' - r'is saved as "\$(?P\w+)"$' + r'is saved as `(?P\w+)`$' ) ) @when( parsers.re( rf'the resulting `(?P{_PATH})` ' - r'is saved as "\$(?P\w+)"$' + r'is saved as `(?P\w+)`$' ) ) def _the_resulting_property_is_saved_as( @@ -1146,9 +1177,8 @@ def _almost_eventually_under_given_or_when() -> None: @when(parsers.re(rf'.+ has {_ASSERT_CLAUSES}$')) def _almost_asserting_under_given_or_when() -> None: raise ValueError( - "Almost: a Given or When 'has' saves, e.g. `name` saved as " - "\"$name\"; assert `path=value` properties with a Then " - "instead" + "Almost: a Given or When 'has' saves, e.g. `path` saved as " + "`name`; assert `path=value` properties with a Then instead" ) @@ -1229,7 +1259,7 @@ def _almost_predicate_in_call_with() -> None: def _almost_missing_backticks() -> None: raise ValueError( "Almost: each clause goes in backticks, e.g. `amount=50` " - 'or `amount` saved as "$amount"' + "or `amount` saved as `amount`" ) diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 29dc14026..04b6a16a4 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -23,18 +23,18 @@ Feature: Accounts Scenario: Steps can save result properties Given an `Account` for "eve" gets created via `open` with `initial_balance=9` - And the resulting `account_id` is saved as "$eve_account" - When the `Account` for "$eve_account" gets a `deposit` with `amount=1` - And the resulting `updated_balance` is saved as "$balance" - And the `Account` for "$eve_account" gets a `deposit` with `amount=$balance` - When `balance` on the `Account` for "$eve_account" has `balance` saved as "$current" - And the `Account` for "$eve_account" gets a `deposit` with `amount=$current` - Then `balance` on the `Account` for "$eve_account" has `balance=40` + And the resulting `account_id` is saved as `eve_account` + When the `Account` for "${eve_account}" gets a `deposit` with `amount=1` + And the resulting `updated_balance` is saved as `balance` + And the `Account` for "${eve_account}" gets a `deposit` with `amount=${balance}` + When `balance` on the `Account` for "${eve_account}" has `balance` saved as `current` + And the `Account` for "${eve_account}" gets a `deposit` with `amount=${current}` + Then `balance` on the `Account` for "${eve_account}" has `balance=40` Scenario: Saving during setup Given an `Account` for "gus" gets created via `open` with `initial_balance=7` - And `balance` on the `Account` for "gus" has `balance` saved as "$initial" and `balance` saved as "$twin" - When the `Account` for "gus" gets a `deposit` with `amount=$initial` + And `balance` on the `Account` for "gus" has `balance` saved as `initial` and `balance` saved as `twin` + When the `Account` for "gus" gets a `deposit` with `amount=${initial}` Then `balance` on the `Account` for "gus" has `balance=14` Scenario: Properties can be messages @@ -47,12 +47,12 @@ Feature: Accounts And `get_owner` on the `Account` for "frank" has `owner.tags[0]="pro"` And `get_owner` on the `Account` for "frank" has `owner.name` containing "rank" and `owner.tags` of length 1 And `get_owner` on the `Account` for "frank" has `owner.tags` containing "pro" - When `get_owner` on the `Account` for "frank" has `owner.name` saved as "$owner_name" - And an `Account` for "$owner_name" gets created via `open` with `initial_balance=1` + When `get_owner` on the `Account` for "frank" has `owner.name` saved as `owner_name` + And an `Account` for "${owner_name}" gets created via `open` with `initial_balance=1` Then `balance` on the `Account` for "Frankie" has `balance=1` - When `get_owner` on the `Account` for "frank" has `owner` saved as "$owner" + When `get_owner` on the `Account` for "frank" has `owner` saved as `owner` And an `Account` for "franklin" gets created via `open` - And the `Account` for "franklin" gets a `set_owner` with `owner=$owner` + And the `Account` for "franklin" gets a `set_owner` with `owner=${owner}` Then `get_owner` on the `Account` for "franklin" has `owner={name: "Frankie", tags: ["pro"]}` Scenario: Readers can abort diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index 1a9f0b935..0fbc5a552 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -78,7 +78,7 @@ def test_the_bearer_token_is() -> None: _the_bearer_token_is(world, 'admin-key') assert world.bearer_token == 'admin-key' world.saved['token'] = 'saved-key' - _the_bearer_token_is(world, '$token') + _the_bearer_token_is(world, '${token}') assert world.bearer_token == 'saved-key' @@ -93,8 +93,8 @@ def test_set_bearer_token_guard() -> None: def test_clause_grammar_routing() -> None: properties = '`balance=50` and `owner.name="F"`' - saves = '`balance` saved as "$b", and `owner` saved as "$o"' - mixed = '`balance=50` and `owner` saved as "$o"' + saves = '`balance` saved as `b`, and `owner` saved as `o`' + mixed = '`balance=50` and `owner` saved as `o`' assert re.fullmatch(_PROPERTY_CLAUSES, properties) assert not re.fullmatch(_PROPERTY_CLAUSES, saves) assert not re.fullmatch(_PROPERTY_CLAUSES, mixed) @@ -113,8 +113,9 @@ def test_clause_grammar_routing() -> None: # Lexical near-misses still route to their kind. assert re.fullmatch(_PROPERTY_CLAUSES, '`amount: 50`') assert re.fullmatch(_PROPERTY_CLAUSES, '`amount = 50`') - assert re.fullmatch(_SAVE_CLAUSES, '`balance` saved to "$b"') - assert re.fullmatch(_SAVE_CLAUSES, '`balance` saved as $b') + assert re.fullmatch(_SAVE_CLAUSES, '`balance` saved to `b`') + assert re.fullmatch(_SAVE_CLAUSES, '`balance` saved as "$b"') + assert re.fullmatch(_SAVE_CLAUSES, '`balance` saved as b') def test_almost_clause_messages() -> None: @@ -133,11 +134,15 @@ def test_almost_clause_messages() -> None: 'name': 'F' } with pytest.raises(ValueError, match="'saved as', not 'saved to'"): - _parse_saves('`balance` saved to "$b"') - with pytest.raises(ValueError, match="quote the name"): - _parse_saves('`balance` saved as $b') - with pytest.raises(ValueError, match=r"the name needs a '\$'"): + _parse_saves('`balance` saved to `b`') + with pytest.raises(ValueError, match=r"drop the '\$'"): + _parse_saves('`balance` saved as "$b"') + with pytest.raises(ValueError, match="backticks, not quotes"): _parse_saves('`balance` saved as "b"') + with pytest.raises(ValueError, match="name goes in backticks"): + _parse_saves('`balance` saved as b') + with pytest.raises(ValueError, match=r"recall a save as \$\{amount\}"): + _parse_assignments(world, '`amount=$amount`') def test_almost_steps_raise() -> None: diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index 38db6c942..0c8ade55d 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -30,9 +30,9 @@ Feature: Accounts with a pydantic API And `get_owner` on the `Account` for "frank" has `owner.tags[0]="pro"` And `get_owner` on the `Account` for "frank" has `owner.name` containing "rank" and `owner.tags` of length 1 And `get_owner` on the `Account` for "frank" has `owner.tags` containing "pro" - When `get_owner` on the `Account` for "frank" has `owner` saved as "$owner" + When `get_owner` on the `Account` for "frank" has `owner` saved as `owner` And an `Account` for "franklin" gets created via `open` - And the `Account` for "franklin" gets a `set_owner` with `owner=$owner` + And the `Account` for "franklin" gets a `set_owner` with `owner=${owner}` Then `get_owner` on the `Account` for "franklin" has `owner={name: "Frankie", tags: ["pro"]}` Scenario: Properties reach through maps From 03b01d17126c654845bdcab643fe74bce7d9938c Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 20:18:47 +0000 Subject: [PATCH 11/42] Let `reboot.bdd` scenarios spawn and await tasks 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 `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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/fixtures.py | 44 +++++++++ reboot/bdd/steps.py | 104 +++++++++++++++++++-- tests/reboot/bdd/BUILD.bazel | 1 + tests/reboot/bdd/account.proto | 6 +- tests/reboot/bdd/account_servicer.py | 6 +- tests/reboot/bdd/accounts.feature | 13 +++ tests/reboot/bdd/bdd_tests.py | 10 ++ tests/reboot/bdd/pydantic/accounts.feature | 6 ++ 8 files changed, 181 insertions(+), 9 deletions(-) diff --git a/reboot/bdd/fixtures.py b/reboot/bdd/fixtures.py index c37b6d8a4..a5d371275 100644 --- a/reboot/bdd/fixtures.py +++ b/reboot/bdd/fixtures.py @@ -258,6 +258,50 @@ def request_type( return request_type return None + def task_type( + self, + *, + state_type: str, + method: str, + ) -> Optional[type]: + """The task type of the named method, from the generated + client class's `Task` class, or `None` when there is + none.""" + client_type = self.client_type(state_type) + alias = method.replace('_', '').lower() + 'task' + for name in dir(client_type): + if name.lower() == alias: + task_type = getattr(client_type, name) + if isinstance(task_type, type): + return task_type + return None + + async def spawn( + self, + *, + state_type: str, + state_id: str, + method: str, + assignments: Union[dict[str, JsonValue], list[Assignment]], + ) -> Any: + """Spawns the named method as a task on the named state, + using the specified `assignments` to create a request, and + returns the task handle to await for its response.""" + reference = self.client_type(state_type).ref(state_id) + spawn = getattr(reference.spawn(), method, None) + if not callable(spawn): + raise ValueError(f"`{state_type}` has no method `{method}`") + if not assignments: + return await spawn(self.context()) + return await spawn( + self.context(), + self.request( + state_type=state_type, + method=method, + assignments=assignments, + ), + ) + def request( self, *, diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 2de3dd6ad..a27ba2190 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -36,6 +36,12 @@ def application() -> Application: raw token); say who you are before 'Given a shared context', whose context keeps the token it was created with. +A call runs as a task instead by saying 'gets a `method` ... +spawned with its task id saved as `name`'; the task then awaits as +'the `method` task with id "${name}" of the `Account` completes +within 10 seconds', recording its response as the result. A task ID +a response carries saves and awaits the same way. + A Then 'eventually has' holds a reactive read open until its assertions hold, waiting at most its required bound, e.g.: @@ -79,6 +85,7 @@ def application() -> Application: # fixtures the steps run on. from pydantic import TypeAdapter, ValidationError from pytest_bdd import parsers +from rbt.v1alpha1 import tasks_pb2 from reboot.aio.aborted import Aborted from reboot.aio.applications import Application from reboot.aio.tests import Reboot @@ -309,6 +316,15 @@ def _almost_within_message(within: str) -> str: ) +def _parsed_seconds(within: str) -> float: + """The seconds a wait bound says; raises the 'Almost' fix for a + lexical near-miss.""" + seconds_match = re.fullmatch(r'(\d+(?:\.\d+)?) seconds?', within) + if seconds_match is None: + raise ValueError(_almost_within_message(within)) + return float(seconds_match[1]) + + def _almost_save_message(clause: str) -> str: """The 'Almost' error for a saving clause that is a lexical near-miss of `path` saved as `name`.""" @@ -840,15 +856,35 @@ async def _gets_created_via( ) from aborted -@given(parsers.re(rf'{_STATE} gets a `(?P\w+)`{_PROPERTIES}$')) -@when(parsers.re(rf'{_STATE} gets a `(?P\w+)`{_PROPERTIES}$')) +@given( + parsers.re( + rf'{_STATE} gets a `(?P\w+)`{_PROPERTIES}' + r'(?: spawned with its task id saved as `(?P\w+)`)?$' + ) +) +@when( + parsers.re( + rf'{_STATE} gets a `(?P\w+)`{_PROPERTIES}' + r'(?: spawned with its task id saved as `(?P\w+)`)?$' + ) +) async def _gets_a( world: World, state_type: str, state_id: str, method: str, clauses: Optional[str], + task: Optional[str], ) -> None: + if task is not None: + handle = await world.spawn( + state_type=state_type, + state_id=_maybe_saved(world, state_id), + method=method, + assignments=_parse_assignments(world, clauses), + ) + world.saved[task] = _json_object(handle.task_id) + return if world.is_reader(state_type=state_type, method=method): raise ValueError( f"`{method}` is a reader; read it with " @@ -896,6 +932,50 @@ async def _attempts_a( world.aborted = aborted +@when( + parsers.re( + r'the `(?P\w+)` task with id "\$\{(?P\w+)\}" ' + r'of the `(?P[\w.]+)` completes within ' + r'(?P.+)$' + ) +) +@then( + parsers.re( + r'the `(?P\w+)` task with id "\$\{(?P\w+)\}" ' + r'of the `(?P[\w.]+)` completes within ' + r'(?P.+)$' + ) +) +async def _the_saved_task_completes( + world: World, + method: str, + name: str, + state_type: str, + within: str, +) -> None: + seconds = _parsed_seconds(within) + saved = _saved_value(world, name) + if not isinstance(saved, dict): + raise ValueError( + f"The value saved as `{name}` must be a task ID, but it " + f"is {saved!r}" + ) + task_type = world.task_type(state_type=state_type, method=method) + if task_type is None: + raise ValueError(f"`{state_type}` has no `{method}` task") + task = getattr(task_type, 'retrieve')( + world.context(), + task_id=json_format.ParseDict(saved, tasks_pb2.TaskId()), + ) + try: + world.response = await asyncio.wait_for(task, timeout=seconds) + except asyncio.TimeoutError: + raise AssertionError( + f"Waited {within} for the `{method}` task saved as " + f"`{name}` to complete" + ) from None + + def _assert_aborted( world: World, aborted: Aborted, @@ -990,10 +1070,7 @@ async def _eventually_has( clauses: str, within: str, ) -> None: - seconds_match = re.fullmatch(r'(\d+(?:\.\d+)?) seconds?', within) - if seconds_match is None: - raise ValueError(_almost_within_message(within)) - seconds = float(seconds_match[1]) + seconds = _parsed_seconds(within) assertions = _parse_assertions(world, clauses) if not world.is_reader(state_type=state_type, method=method): raise ValueError( @@ -1148,6 +1225,21 @@ def _the_resulting_property_is_saved_as( # step's tail never matches one of these. +@when( + parsers. + re(r'the `\w+` task with id "\$\{\w+\}" of the `[\w.]+` completes$') +) +@then( + parsers. + re(r'the `\w+` task with id "\$\{\w+\}" of the `[\w.]+` completes$') +) +def _almost_completes_needs_within() -> None: + raise ValueError( + "Almost: say how long to wait for the task, e.g. within 10 " + "seconds" + ) + + @then(parsers.re(rf'.+ eventually has {_ASSERT_CLAUSES}$')) def _almost_eventually_needs_within() -> None: raise ValueError( diff --git a/tests/reboot/bdd/BUILD.bazel b/tests/reboot/bdd/BUILD.bazel index 9a42e1a04..f135c381d 100644 --- a/tests/reboot/bdd/BUILD.bazel +++ b/tests/reboot/bdd/BUILD.bazel @@ -8,6 +8,7 @@ proto_library( srcs = [":account.proto"], deps = [ "//rbt/v1alpha1:options_proto", + "//rbt/v1alpha1:tasks_proto", ], ) diff --git a/tests/reboot/bdd/account.proto b/tests/reboot/bdd/account.proto index 91c42269a..f03727677 100644 --- a/tests/reboot/bdd/account.proto +++ b/tests/reboot/bdd/account.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package tests.reboot.bdd; import "rbt/v1alpha1/options.proto"; +import "rbt/v1alpha1/tasks.proto"; // A bank account, used to test the `reboot.bdd` steps. message Account { @@ -90,7 +91,10 @@ message DepositLaterRequest { int64 amount = 1; } -message DepositLaterResponse {} +message DepositLaterResponse { + // ID of the scheduled deposit task. + rbt.v1alpha1.TaskId task_id = 1; +} message WhoamiRequest {} diff --git a/tests/reboot/bdd/account_servicer.py b/tests/reboot/bdd/account_servicer.py index e8766ae99..22e4df5ee 100644 --- a/tests/reboot/bdd/account_servicer.py +++ b/tests/reboot/bdd/account_servicer.py @@ -53,8 +53,10 @@ async def deposit_later( context: WriterContext, request: DepositLaterRequest, ) -> DepositLaterResponse: - await self.ref().schedule().deposit(context, amount=request.amount) - return DepositLaterResponse() + task_id = await self.ref().schedule().deposit( + context, amount=request.amount + ) + return DepositLaterResponse(task_id=task_id) async def whoami( self, diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 04b6a16a4..63b8c6a6f 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -82,3 +82,16 @@ Feature: Accounts Given an `Account` for "slow" gets created via `open` When the `Account` for "slow" gets a `deposit_later` with `amount=75` Then `balance` on the `Account` for "slow" eventually has `balance=75` within 30 seconds + + Scenario: Spawned tasks complete + Given an `Account` for "spawned" gets created via `open` + 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` + + Scenario: Scheduled tasks are awaited by ID + Given an `Account` for "later" gets created via `open` + When the `Account` for "later" gets a `deposit_later` with `amount=20` + And the resulting `task_id` is saved as `deposit_task_id` + And the `deposit` task with id "${deposit_task_id}" of the `Account` completes within 30 seconds + Then the result has `updated_balance=20` diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index 0fbc5a552..63a601bb6 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -24,6 +24,7 @@ Equals, OfLength, _almost_asserting_under_given_or_when, + _almost_completes_needs_within, _almost_eventually_needs_within, _almost_eventually_under_given_or_when, _almost_missing_backticks, @@ -67,6 +68,13 @@ async def _makes_deposits( await Account.ref(state_id).deposit(context, amount=amount) +def test_task_type() -> None: + world = World(client_types={'tests.reboot.bdd.Account': Account}) + task_type = world.task_type(state_type='Account', method='deposit') + assert task_type is not None and hasattr(task_type, 'retrieve') + assert world.task_type(state_type='Account', method='nothing') is None + + def test_is_reader() -> None: world = World(client_types={'tests.reboot.bdd.Account': Account}) assert world.is_reader(state_type='Account', method='balance') @@ -158,6 +166,8 @@ def test_almost_steps_raise() -> None: _almost_predicate_in_call_with() with pytest.raises(ValueError, match="say how long"): _almost_eventually_needs_within() + with pytest.raises(ValueError, match="how long to wait for the task"): + _almost_completes_needs_within() with pytest.raises(ValueError, match="goes with 'eventually has'"): _almost_within_needs_eventually() with pytest.raises(ValueError, match="asserts, under a Then"): diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index 0c8ade55d..177c5eb11 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -53,3 +53,9 @@ Feature: Accounts with a pydantic API Given an `Account` for "slow" gets created via `open` When the `Account` for "slow" gets a `deposit_later` with `amount=75` Then `balance` on the `Account` for "slow" eventually has `balance=75` within 30 seconds + + Scenario: Spawned tasks complete + Given an `Account` for "spawned" gets created via `open` + 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` From 1a39c3639299e2fc08fad54bee81a62363f58312 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 21:06:01 +0000 Subject: [PATCH 12/42] Say 'the authenticated user is' in `reboot.bdd`, not 'I am' '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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/fixtures.py | 3 ++- reboot/bdd/steps.py | 17 +++++++++-------- tests/reboot/bdd/accounts.feature | 6 +++--- tests/reboot/bdd/pydantic/accounts.feature | 6 +++--- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/reboot/bdd/fixtures.py b/reboot/bdd/fixtures.py index a5d371275..950278836 100644 --- a/reboot/bdd/fixtures.py +++ b/reboot/bdd/fixtures.py @@ -187,7 +187,8 @@ def set_bearer_token(self, bearer_token: str) -> None: if self.shared_context is not None: raise ValueError( "The shared context already carries an identity; say " - "who you are before 'Given a shared context'" + "who the authenticated user is before 'Given a " + "shared context'" ) self.bearer_token = bearer_token diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index a27ba2190..18453a460 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -30,11 +30,12 @@ def application() -> Application: Then `balance` on the `Account` for "alice" has `balance=50` -A scenario says who it calls as with 'Given I am "alice"', which -mints a test token for that user ID and puts it on every context -created from then on ('the bearer token is "..." ' instead sets a -raw token); say who you are before 'Given a shared context', whose -context keeps the token it was created with. +A scenario says who it calls as with 'Given the authenticated +user is "alice"', which mints a test token for that user ID and +puts it on every context created from then on ('the bearer token is +"..." ' instead sets a raw token); say who the authenticated user +is before 'Given a shared context', whose context keeps the token +it was created with. A call runs as a task instead by saying 'gets a `method` ... spawned with its task id saved as `name`'; the task then awaits as @@ -793,9 +794,9 @@ async def _the_application_is_up( world.name = request.node.name -@given(parsers.re(r'I am "(?P[^"]*)"$')) -@when(parsers.re(r'I am "(?P[^"]*)"$')) -async def _i_am(world: World, user_id: str) -> None: +@given(parsers.re(r'the authenticated user is "(?P[^"]*)"$')) +@when(parsers.re(r'the authenticated user is "(?P[^"]*)"$')) +async def _the_authenticated_user_is(world: World, user_id: str) -> None: if world.rbt is None: raise ValueError( "The application is not up; start the scenario with " diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 63b8c6a6f..569661fcc 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -71,11 +71,11 @@ Feature: Accounts When the `Account` for "dave" gets a `deposit` with `amount=5` Then `balance` on the `Account` for "dave" has `balance=5` - Scenario: Steps call as who I am - Given I am "alice" + Scenario: Steps call as the authenticated user + Given the authenticated user is "alice" And an `Account` for "joint" gets created via `open` Then `whoami` on the `Account` for "joint" has `user_id="alice"` - When I am "bob" + When the authenticated user is "bob" Then `whoami` on the `Account` for "joint" has `user_id="bob"` Scenario: Effects land eventually diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index 177c5eb11..65c1ba394 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -42,11 +42,11 @@ Feature: Accounts with a pydantic API And `get_owners` on the `Account` for "heidi" has `owners={main: {name: "Heidi", tags: ["a"]}}` And `get_owners` on the `Account` for "heidi" has `owners` containing "main" and `owners` of length 1 - Scenario: Steps call as who I am - Given I am "alice" + Scenario: Steps call as the authenticated user + Given the authenticated user is "alice" And an `Account` for "joint" gets created via `open` Then `whoami` on the `Account` for "joint" has `user_id="alice"` - When I am "bob" + When the authenticated user is "bob" Then `whoami` on the `Account` for "joint" has `user_id="bob"` Scenario: Effects land eventually From 78f81d08749d57fe50cf8fde44dbeac026b3b3f9 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 21:09:29 +0000 Subject: [PATCH 13/42] Require `reboot.bdd` scenarios to say who calls 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/fixtures.py | 20 +++++++++++--- reboot/bdd/steps.py | 31 +++++++++++++++++----- tests/reboot/bdd/accounts.feature | 1 + tests/reboot/bdd/bdd_tests.py | 9 +++++++ tests/reboot/bdd/collisions.feature | 1 + tests/reboot/bdd/pydantic/accounts.feature | 1 + 6 files changed, 53 insertions(+), 10 deletions(-) diff --git a/reboot/bdd/fixtures.py b/reboot/bdd/fixtures.py index 950278836..80c43b352 100644 --- a/reboot/bdd/fixtures.py +++ b/reboot/bdd/fixtures.py @@ -160,9 +160,13 @@ class World: aborted: Optional[Aborted] = None # The bearer token every context created from here on carries; - # `None` calls anonymously. + # `None` calls unauthenticated. bearer_token: Optional[str] = None + # Whether the scenario has said who calls, authenticated or + # not; every call requires it. + user_declared: bool = False + def context(self) -> ExternalContext: """The context for one step's call: the scenario's shared context once a 'Given a shared context' step has created it, @@ -174,16 +178,23 @@ def context(self) -> ExternalContext: "The application is not up; start the scenario with " "'Given the application is up'" ) + if not self.user_declared: + raise ValueError( + "The scenario has not declared a user; say 'Given " + 'the authenticated user is "..."\' or \'Given the ' + "user is unauthenticated'" + ) self.contexts_created += 1 return self.rbt.create_external_context( name=f"{self.name}-{self.contexts_created}", bearer_token=self.bearer_token, ) - def set_bearer_token(self, bearer_token: str) -> None: + def set_bearer_token(self, bearer_token: Optional[str]) -> None: """Sets the bearer token every context created from here on - carries; raises once a shared context exists, which keeps the - token it was created with.""" + carries, `None` for unauthenticated, satisfying the say-who- + calls requirement either way; raises once a shared context + exists, which keeps the token it was created with.""" if self.shared_context is not None: raise ValueError( "The shared context already carries an identity; say " @@ -191,6 +202,7 @@ def set_bearer_token(self, bearer_token: str) -> None: "shared context'" ) self.bearer_token = bearer_token + self.user_declared = True def client_type(self, state_type: str) -> Any: """The generated client class of the named state type, named diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 18453a460..00b3c1139 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -30,12 +30,13 @@ def application() -> Application: Then `balance` on the `Account` for "alice" has `balance=50` -A scenario says who it calls as with 'Given the authenticated -user is "alice"', which mints a test token for that user ID and -puts it on every context created from then on ('the bearer token is -"..." ' instead sets a raw token); say who the authenticated user -is before 'Given a shared context', whose context keeps the token -it was created with. +Every scenario says who calls before its first call: 'Given the +authenticated user is "alice"' mints a test token for that user ID +and puts it on every context created from then on, 'Given the user +is unauthenticated' calls with no token, and 'the bearer token is +"..."' instead sets a raw token; say who calls before 'Given a +shared context', whose context keeps the token it was created +with. A call runs as a task instead by saying 'gets a `method` ... spawned with its task id saved as `name`'; the task then awaits as @@ -809,6 +810,12 @@ async def _the_authenticated_user_is(world: World, user_id: str) -> None: ) +@given('the user is unauthenticated') +@when('the user is unauthenticated') +def _the_user_is_unauthenticated(world: World) -> None: + world.set_bearer_token(None) + + @given(parsers.re(r'the bearer token is "(?P[^"]*)"$')) @when(parsers.re(r'the bearer token is "(?P[^"]*)"$')) def _the_bearer_token_is(world: World, bearer_token: str) -> None: @@ -1257,6 +1264,18 @@ def _almost_within_needs_eventually() -> None: ) +@given(parsers.re(r'I am "[^"]*"$')) +@when(parsers.re(r'I am "[^"]*"$')) +def _almost_i_am() -> None: + raise ValueError("Almost: say 'the authenticated user is \"...\"'") + + +@given('the user is anonymous') +@when('the user is anonymous') +def _almost_anonymous() -> None: + raise ValueError("Almost: say 'the user is unauthenticated'") + + @given(parsers.re(r'.+ eventually has .+$')) @when(parsers.re(r'.+ eventually has .+$')) def _almost_eventually_under_given_or_when() -> None: diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 569661fcc..6700760df 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -2,6 +2,7 @@ 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` diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index 63a601bb6..be4417de2 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -11,6 +11,7 @@ import re from pytest_bdd import parsers, scenarios from reboot.aio.external import ExternalContext +from reboot.aio.tests import Reboot from reboot.bdd import when from reboot.bdd.fixtures import JsonValue, PropertyPath, World from reboot.bdd.steps import * @@ -90,6 +91,14 @@ def test_the_bearer_token_is() -> None: assert world.bearer_token == 'saved-key' +def test_context_requires_user_declared() -> None: + world = World(rbt=cast(Reboot, object()), name='test') + with pytest.raises(ValueError, match="has not declared a user"): + world.context() + world.set_bearer_token(None) + assert world.user_declared + + def test_set_bearer_token_guard() -> None: world = World() world.set_bearer_token('token') diff --git a/tests/reboot/bdd/collisions.feature b/tests/reboot/bdd/collisions.feature index ea66c2631..94e20d76e 100644 --- a/tests/reboot/bdd/collisions.feature +++ b/tests/reboot/bdd/collisions.feature @@ -2,6 +2,7 @@ Feature: Colliding state type names Background: Given the application is up + And the user is unauthenticated Scenario: Full state type names disambiguate Given a `tests.reboot.bdd.Account` for "alice" gets created via `open` with `initial_balance=1` diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index 65c1ba394..611321194 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -2,6 +2,7 @@ Feature: Accounts with a pydantic API 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` From 3adcf7b692c44ff1ed734438e16ee02063d240ba Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 21:17:54 +0000 Subject: [PATCH 14/42] Let `reboot.bdd` scenarios choose their application by name '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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/steps.py | 36 ++++++++++++++++++++++++++++-- tests/reboot/bdd/BUILD.bazel | 1 + tests/reboot/bdd/bdd_tests.py | 12 ++++++++++ tests/reboot/bdd/variation.feature | 13 +++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/reboot/bdd/variation.feature diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 00b3c1139..010d8e303 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -13,6 +13,12 @@ def application() -> Application: return Application(servicers=[AccountServicer]) +A scenario runs a different application by naming it: 'Given the +"proxy" application is up' runs the one the `proxy_application` +fixture returns (the quoted name, spaces as underscores, plus +`_application`), so the scenarios of one feature file vary the +application under test. + Step text refers to a state type by its class name in backticks (or by its full state type name, e.g. `bank.v1.Account`, when more than one state type goes by the class name), to a state's ID in double @@ -782,13 +788,30 @@ def _assert_properties( _assert_of_length(path, actual, length) -@given('the application is up') +@given(parsers.re(r'the (?:"(?P[^"]*)" )?application is up$')) async def _the_application_is_up( rbt: Reboot, - application: Application, world: World, request: pytest.FixtureRequest, + name: Optional[str], ) -> None: + fixture = ( + 'application' if name is None else name.replace(' ', '_') + + '_application' + ) + try: + application = request.getfixturevalue(fixture) + except pytest.FixtureLookupError: + raise ValueError( + f"No `{fixture}` fixture (the quoted name, spaces as " + "underscores, plus `_application`); define one " + "returning the `Application` the scenario runs" + ) from None + if not isinstance(application, Application): + raise ValueError( + f"Expecting the `{fixture}` fixture to return an " + f"`Application`, but it returned {application!r}" + ) await rbt.up(application) world.client_types = client_types_by_name(application) world.rbt = rbt @@ -1264,6 +1287,15 @@ def _almost_within_needs_eventually() -> None: ) +@given(parsers.re(r'the \w+ application is up$')) +@when(parsers.re(r'the \w+ application is up$')) +def _almost_unquoted_application() -> None: + raise ValueError( + "Almost: quote the application's name, e.g. 'the \"proxy\" " + "application is up'" + ) + + @given(parsers.re(r'I am "[^"]*"$')) @when(parsers.re(r'I am "[^"]*"$')) def _almost_i_am() -> None: diff --git a/tests/reboot/bdd/BUILD.bazel b/tests/reboot/bdd/BUILD.bazel index f135c381d..54cd320b4 100644 --- a/tests/reboot/bdd/BUILD.bazel +++ b/tests/reboot/bdd/BUILD.bazel @@ -46,6 +46,7 @@ py_test( data = [ ":accounts.feature", ":collisions.feature", + ":variation.feature", ], main = "pytest_main.py", deps = [ diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index be4417de2..90cc135b7 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -10,6 +10,7 @@ import pytest import re from pytest_bdd import parsers, scenarios +from reboot.aio.applications import Application from reboot.aio.external import ExternalContext from reboot.aio.tests import Reboot from reboot.bdd import when @@ -51,9 +52,19 @@ Owner, ) from tests.reboot.bdd.account_rbt import Account +from tests.reboot.bdd.account_servicer import AccountServicer +from tests.reboot.bdd.other.account_servicer import \ + AccountServicer as OtherAccountServicer from typing import cast +# An application a scenario picks by name: 'Given the two_accounts +# application is up'. +@pytest.fixture +def two_accounts_application() -> Application: + return Application(servicers=[AccountServicer, OtherAccountServicer]) + + # A custom `async def` step, the way a developer would write one: it # runs on the same event loop as the built-in steps and can call the # generated code directly. @@ -329,3 +340,4 @@ def test_assert_properties_proto_semantics() -> None: scenarios('accounts.feature') +scenarios('variation.feature') diff --git a/tests/reboot/bdd/variation.feature b/tests/reboot/bdd/variation.feature new file mode 100644 index 000000000..407477611 --- /dev/null +++ b/tests/reboot/bdd/variation.feature @@ -0,0 +1,13 @@ +Feature: Choosing the application + + Scenario: A scenario picks its application by name + Given the "two accounts" application is up + And the user is unauthenticated + And a `tests.reboot.bdd.other.Account` for "vary" gets created via `open` with `initial_total=7` + Then `total` on the `tests.reboot.bdd.other.Account` for "vary" has `total=7` + + Scenario: The unnamed application is the `application` fixture + Given the application is up + And the user is unauthenticated + And an `Account` for "vary" gets created via `open` with `initial_balance=3` + Then `balance` on the `Account` for "vary" has `balance=3` From 542bc1d86bcdf73fd1dcfa21f2a8787b85c19258 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 21:37:48 +0000 Subject: [PATCH 15/42] Ship `reboot.bdd` in the wheel as the `reboot[pytest-bdd]` extra 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- bazel/pip_package_rule/BUILD.bazel | 6 ++ bazel/pip_package_rule/build_trampoline.py | 25 +++--- bazel/pip_package_rule/pip_package.bzl | 90 +++++++++++++++++-- .../pip_package_rule/pyproject.toml.template | 4 +- bazel/pip_package_rule/strip_requirements.py | 85 ++++++++++++++++++ reboot/BUILD.bazel | 5 ++ reboot/requirements-pytest-bdd.in | 9 ++ 7 files changed, 203 insertions(+), 21 deletions(-) create mode 100644 bazel/pip_package_rule/strip_requirements.py create mode 100644 reboot/requirements-pytest-bdd.in diff --git a/bazel/pip_package_rule/BUILD.bazel b/bazel/pip_package_rule/BUILD.bazel index d9b129c05..cbf3c64f3 100644 --- a/bazel/pip_package_rule/BUILD.bazel +++ b/bazel/pip_package_rule/BUILD.bazel @@ -21,6 +21,12 @@ exports_files( # A trampoline for the `pip_package` Bazel rule to invoke Python's `build` tool. # Should only be used via the `pip_package` Bazel rule. +py_binary( + name = "strip_requirements", + srcs = ["strip_requirements.py"], + visibility = ["//visibility:public"], +) + py_binary( name = "build_trampoline", srcs = ["build_trampoline.py"], diff --git a/bazel/pip_package_rule/build_trampoline.py b/bazel/pip_package_rule/build_trampoline.py index 1aa9e078b..4c9b8e7b5 100644 --- a/bazel/pip_package_rule/build_trampoline.py +++ b/bazel/pip_package_rule/build_trampoline.py @@ -76,6 +76,7 @@ def find_package_name(line: str) -> str: parser.add_argument( "--requirements-txt", type=str, + action="append", help="the path to the requirements.txt file", required=True, ) @@ -114,17 +115,19 @@ def find_package_name(line: str) -> str: normalize_package_name(dep) for dep in args.verify_dependency_in_requirements ] - with open(args.requirements_txt) as requirements_txt: - for line in requirements_txt: - if line.startswith("#"): - continue - dependency = normalize_package_name(find_package_name(line)) - try: - # This dependency is no longer missing! - missing_dependencies.remove(dependency) - except ValueError: - # Turns out we don't need this dependency. That's fine. - pass + for requirements_txt_path in args.requirements_txt: + with open(requirements_txt_path) as requirements_txt: + for line in requirements_txt: + if line.startswith("#"): + continue + dependency = normalize_package_name(find_package_name(line)) + try: + # This dependency is no longer missing! + missing_dependencies.remove(dependency) + except ValueError: + # Turns out we don't need this dependency. That's + # fine. + pass if len(missing_dependencies) > 0: raise MissingDependenciesError( f"Expected dependencies {missing_dependencies} to be in the " diff --git a/bazel/pip_package_rule/pip_package.bzl b/bazel/pip_package_rule/pip_package.bzl index cc91058ef..0e4f1cca6 100644 --- a/bazel/pip_package_rule/pip_package.bzl +++ b/bazel/pip_package_rule/pip_package.bzl @@ -560,16 +560,56 @@ def _pip_package_impl(ctx): staged_requirements_txt = ctx.actions.declare_file( "%s/requirements.txt" % STAGING_DIRECTORY_NAME, ) - ctx.actions.run_shell( - mnemonic = "StageRequirements", - command = "cp %s %s" % ( + extras_requirements_txts = {} + for extra_target, extra_name in ctx.attr.extras.items(): + extra_files = extra_target[DefaultInfo].files.to_list() + if len(extra_files) != 1: + fail("Expected exactly one requirements file for extra '%s'" % + extra_name) + extras_requirements_txts[extra_name] = extra_files[0] + if extras_requirements_txts: + strip_arguments = [ + "--requirements-txt", input_requirements_txt.path, + "--output", staged_requirements_txt.path, - ), - inputs = [input_requirements_txt], - outputs = [staged_requirements_txt], - ) + ] + for extra_file in extras_requirements_txts.values(): + strip_arguments.append( + "--extra-requirements-txt=%s" % extra_file.path, + ) + ctx.actions.run( + mnemonic = "StripExtrasRequirements", + executable = ctx.executable._strip_requirements_tool, + arguments = strip_arguments, + inputs = [input_requirements_txt] + + extras_requirements_txts.values(), + outputs = [staged_requirements_txt], + ) + else: + ctx.actions.run_shell( + mnemonic = "StageRequirements", + command = "cp %s %s" % ( + input_requirements_txt.path, + staged_requirements_txt.path, + ), + inputs = [input_requirements_txt], + outputs = [staged_requirements_txt], + ) metadata_files.append(staged_requirements_txt) + staged_extras_requirements_txts = [] + for extra_name, extra_file in extras_requirements_txts.items(): + staged_extra = ctx.actions.declare_file( + "%s/requirements-%s.txt" % (STAGING_DIRECTORY_NAME, extra_name), + ) + ctx.actions.run_shell( + mnemonic = "StageExtraRequirements", + command = "cp %s %s" % (extra_file.path, staged_extra.path), + inputs = [extra_file], + outputs = [staged_extra], + ) + metadata_files.append(staged_extra) + staged_extras_requirements_txts.append(staged_extra) ### pyproject.toml # The `pyproject.toml` file is the main configuration file that will control @@ -623,14 +663,28 @@ def _pip_package_impl(ctx): version = ctx.attr.version license = ctx.attr.license pyproject_toml_file = ctx.actions.declare_file("%s/pyproject.toml" % STAGING_DIRECTORY_NAME) + dynamic_fields = '["dependencies"]' + optional_dependencies = "" + if extras_requirements_txts: + dynamic_fields = '["dependencies", "optional-dependencies"]' + optional_dependencies = ( + "[tool.setuptools.dynamic.optional-dependencies]\n" + ) + for extra_name in extras_requirements_txts.keys(): + optional_dependencies += ( + '%s = { file = ["requirements-%s.txt"] }\n' % + (extra_name, extra_name) + ) ctx.actions.expand_template( template = ctx.file._toml_template, output = pyproject_toml_file, substitutions = { "{CLASSIFIERS}": str(classifiers), "{DESCRIPTION}": ctx.attr.description, + "{DYNAMIC_FIELDS}": dynamic_fields, "{LICENSE}": license, "{NAME}": ctx.attr.distribution_name, + "{OPTIONAL_DEPENDENCIES}": optional_dependencies, "{PACKAGE_DATA}": package_data, "{SCRIPTS}": scripts, "{VERSION}": version, @@ -711,8 +765,11 @@ def _pip_package_impl(ctx): output_directory, "--verify-python-version", ctx.attr._python_version, - "--requirements-txt", - staged_requirements_txt.path, + ] + [ + "--requirements-txt=%s" % requirements.path + for requirements in ( + [staged_requirements_txt] + staged_extras_requirements_txts + ) ] + [ "--verify-dependency-in-requirements=%s" % dependency for dependency in pypi_dependencies.keys() @@ -768,6 +825,16 @@ _pip_package = rule( doc = "The name of the distribution to generate.", mandatory = True, ), + "extras": attr.label_keyed_string_dict( + allow_files = True, + default = {}, + doc = "Optional-dependency extras: each key is a " + + "requirements file and each value the extra's name; " + + "the file's packages ship as that extra instead of " + + "as dependencies, and every one of them must also " + + "be in `requirements_txt`, which stays the " + + "complete list.", + ), "license": attr.string( doc = "The license to use for the pip package.", mandatory = True, @@ -823,6 +890,11 @@ _pip_package = rule( default = Label("//bazel/pip_package_rule:setup.py.template"), allow_single_file = True, ), + "_strip_requirements_tool": attr.label( + default = Label("//bazel/pip_package_rule:strip_requirements"), + executable = True, + cfg = "exec", + ), "_toml_template": attr.label( # A pointer to the template for the `pyproject.toml` file. default = Label("//bazel/pip_package_rule:pyproject.toml.template"), diff --git a/bazel/pip_package_rule/pyproject.toml.template b/bazel/pip_package_rule/pyproject.toml.template index ddee217c2..55bb9c5a1 100644 --- a/bazel/pip_package_rule/pyproject.toml.template +++ b/bazel/pip_package_rule/pyproject.toml.template @@ -3,7 +3,7 @@ name = "{NAME}" version = "{VERSION}" # TODO: See https://github.com/reboot-dev/mono/issues/4200. requires-python = ">=3.10,<3.13" -dynamic = ["dependencies"] +dynamic = {DYNAMIC_FIELDS} readme = "README.md" description = "{DESCRIPTION}" classifiers = {CLASSIFIERS} @@ -12,6 +12,8 @@ license = "{LICENSE}" [tool.setuptools.dynamic] dependencies = { file = "requirements.txt" } +{OPTIONAL_DEPENDENCIES} + [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" diff --git a/bazel/pip_package_rule/strip_requirements.py b/bazel/pip_package_rule/strip_requirements.py new file mode 100644 index 000000000..ac5cba08c --- /dev/null +++ b/bazel/pip_package_rule/strip_requirements.py @@ -0,0 +1,85 @@ +"""Writes a requirements file with the packages of the given extras +requirements files removed, so that those packages ship as a wheel's +optional-dependency extras instead of its dependencies.""" + +import argparse +import re + + +def normalize_package_name(name: str) -> str: + """ + Normalizes the package name per + https://packaging.python.org/en/latest/specifications/name-normalization/#normalization + """ + return re.sub(r"[-_.]+", "-", name).lower() + + +def find_package_name(line: str) -> str: + """ + Given a line like: + ``` + my-cool_pAcKaG3.n4me==1.2.3 # some comment. + ``` + Returns "my-cool.pAcKaG3.n4me". + """ + package_name = re.match("^([\\w._-]+)", line, flags=re.IGNORECASE) + if package_name is None: + raise ValueError(f"Could not find package name in line: '{line}'") + + return package_name.group(1) + + +def requirement_packages(filename: str) -> set[str]: + """The normalized package names the requirements file pins.""" + packages = set() + with open(filename) as requirements: + for line in requirements: + line = line.strip() + if not line or line.startswith("#"): + continue + packages.add(normalize_package_name(find_package_name(line))) + return packages + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--requirements-txt", type=str, required=True) + parser.add_argument("--output", type=str, required=True) + parser.add_argument( + "--extra-requirements-txt", + type=str, + action="append", + default=[], + help="requirements file whose packages become an extra, so " + "they are removed from the output; every one of its packages " + "must be present in --requirements-txt", + ) + args = parser.parse_args() + + extras_packages = set() + for filename in args.extra_requirements_txt: + extras_packages.update(requirement_packages(filename)) + + missing = extras_packages - requirement_packages(args.requirements_txt) + if missing: + raise ValueError( + f"Expected the extras packages {sorted(missing)} to also be " + f"in '{args.requirements_txt}', which stays the complete " + "list, but they were not" + ) + + with open(args.requirements_txt) as requirements: + with open(args.output, "w") as output: + for line in requirements: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + package = normalize_package_name( + find_package_name(stripped) + ) + if package in extras_packages: + continue + output.write(line) + + +if __name__ == "__main__": + main() diff --git a/reboot/BUILD.bazel b/reboot/BUILD.bazel index 8dc114207..07119ac18 100644 --- a/reboot/BUILD.bazel +++ b/reboot/BUILD.bazel @@ -10,6 +10,7 @@ exports_files([ "LICENSE.txt", "versions.bzl", "requirements.in", + "requirements-pytest-bdd.in", ]) py_library( @@ -543,6 +544,7 @@ py_library( ":python_std", ":python_thirdparty", "//reboot/aio:python", + "//reboot/bdd:steps_py", "//reboot/cli:main_py", "//reboot/dashboard/backend:main_py", "//reboot/mcp:python", @@ -562,6 +564,9 @@ pip_package( # coincidence and does not refer to the company name. name = "reboot", description = "The Reboot library", + extras = { + "//reboot:requirements-pytest-bdd.in": "pytest-bdd", + }, license = "Apache-2.0", license_txt = ":LICENSE.txt", readme_md = "//:README.md", diff --git a/reboot/requirements-pytest-bdd.in b/reboot/requirements-pytest-bdd.in new file mode 100644 index 000000000..cdfc9c1b9 --- /dev/null +++ b/reboot/requirements-pytest-bdd.in @@ -0,0 +1,9 @@ +# Requirements of the `reboot[pytest-bdd]` extra: what `reboot.bdd` +# needs beyond the `reboot` package itself. Every line must also be +# in `requirements.in`, which stays the complete list that the Bazel +# lock compiles; the wheel build strips these from the package's +# dependencies and ships them as the extra. +json5==0.15.0 # For `reboot.bdd`; latest as of 2026/09/03. +jsonpath-ng==1.8.0 # For `reboot.bdd`; latest as of 2026/09/03. +pytest==8.4.2 # For `reboot.bdd`; latest 8.x as of 2026/09/01. +pytest-bdd==8.1.0 # For `reboot.bdd`; latest as of 2026/09/01. From 065b44d3fc36c6495e414c9ce409d9e7c5581e7d Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 22:07:04 +0000 Subject: [PATCH 16/42] Register `reboot.bdd`'s steps as a pytest plugin 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- bazel/pip_package_rule/pip_package.bzl | 18 ++++++++++++++++++ bazel/pip_package_rule/pyproject.toml.template | 2 ++ reboot/BUILD.bazel | 13 ++++++++++++- reboot/bdd_plugin.py | 8 ++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 reboot/bdd_plugin.py diff --git a/bazel/pip_package_rule/pip_package.bzl b/bazel/pip_package_rule/pip_package.bzl index 0e4f1cca6..8fca0a931 100644 --- a/bazel/pip_package_rule/pip_package.bzl +++ b/bazel/pip_package_rule/pip_package.bzl @@ -663,6 +663,16 @@ def _pip_package_impl(ctx): version = ctx.attr.version license = ctx.attr.license pyproject_toml_file = ctx.actions.declare_file("%s/pyproject.toml" % STAGING_DIRECTORY_NAME) + entry_points = "" + for group, entries in ctx.attr.entry_points.items(): + entry_points += "[project.entry-points.%s]\n" % group + for entry in entries: + name, separator, module = entry.partition("=") + if separator == "": + fail("Expected an entry_points entry of the form " + + "'name = module', but got '%s'" % entry) + entry_points += '%s = "%s"\n' % (name.strip(), module.strip()) + dynamic_fields = '["dependencies"]' optional_dependencies = "" if extras_requirements_txts: @@ -682,6 +692,7 @@ def _pip_package_impl(ctx): "{CLASSIFIERS}": str(classifiers), "{DESCRIPTION}": ctx.attr.description, "{DYNAMIC_FIELDS}": dynamic_fields, + "{ENTRY_POINTS}": entry_points, "{LICENSE}": license, "{NAME}": ctx.attr.distribution_name, "{OPTIONAL_DEPENDENCIES}": optional_dependencies, @@ -825,6 +836,13 @@ _pip_package = rule( doc = "The name of the distribution to generate.", mandatory = True, ), + "entry_points": attr.string_list_dict( + default = {}, + doc = "The entry points this pip package advertises: " + + "each key is an entry-point group, e.g. " + + "'pytest11', and each value that group's " + + "'name = module' entries.", + ), "extras": attr.label_keyed_string_dict( allow_files = True, default = {}, diff --git a/bazel/pip_package_rule/pyproject.toml.template b/bazel/pip_package_rule/pyproject.toml.template index 55bb9c5a1..b9d51df5c 100644 --- a/bazel/pip_package_rule/pyproject.toml.template +++ b/bazel/pip_package_rule/pyproject.toml.template @@ -21,6 +21,8 @@ build-backend = "setuptools.build_meta" [project.scripts] {SCRIPTS} +{ENTRY_POINTS} + [tool.setuptools] # The default settings understand our `src/` layout and will auto-discover the # Python packages we've placed there. We don't need to specify them here. diff --git a/reboot/BUILD.bazel b/reboot/BUILD.bazel index 07119ac18..6f7dff04b 100644 --- a/reboot/BUILD.bazel +++ b/reboot/BUILD.bazel @@ -499,6 +499,14 @@ compile_pip_requirements( requirements_txt = ":requirements_lock.txt", ) +py_library( + name = "bdd_plugin_py", + srcs = ["bdd_plugin.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = ["//reboot/bdd:steps_py"], +) + py_library( name = "python_std", deps = [ @@ -533,6 +541,7 @@ py_library( ], deps = [ ":api_py", + ":bdd_plugin_py", ":protobuf_py", ":protoc_gen_es_with_deps_py", ":protoc_gen_reboot_nodejs_boilerplate_py", @@ -544,7 +553,6 @@ py_library( ":python_std", ":python_thirdparty", "//reboot/aio:python", - "//reboot/bdd:steps_py", "//reboot/cli:main_py", "//reboot/dashboard/backend:main_py", "//reboot/mcp:python", @@ -564,6 +572,9 @@ pip_package( # coincidence and does not refer to the company name. name = "reboot", description = "The Reboot library", + entry_points = { + "pytest11": ["reboot-bdd = reboot.bdd_plugin"], + }, extras = { "//reboot:requirements-pytest-bdd.in": "pytest-bdd", }, diff --git a/reboot/bdd_plugin.py b/reboot/bdd_plugin.py new file mode 100644 index 000000000..46d155d9c --- /dev/null +++ b/reboot/bdd_plugin.py @@ -0,0 +1,8 @@ +"""The pytest plugin the `reboot` distribution registers: with the +`reboot[pytest-bdd]` extra installed, every test run gets the +`reboot.bdd` steps and fixtures.""" + +import importlib.util + +if importlib.util.find_spec('pytest_bdd') is not None: + from reboot.bdd.steps import * # noqa: F401,F403 From 77d8c2453361c1c6ce5dbaa9d22da3f3c7955ea3 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 21:44:55 +0000 Subject: [PATCH 17/42] Write the chat-room example's tests in Gherkin 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- documentation/docs/learn_more/testing.md | 6 --- reboot/examples/chat-room/.tests/test.sh | 2 +- .../chat-room/backend/tests/chat_room.feature | 13 ++++++ .../backend/tests/chat_room_servicer_test.py | 42 ++++--------------- reboot/examples/chat-room/pyproject.toml | 2 +- reboot/examples/chat-room/uv.lock | 8 ++-- 6 files changed, 28 insertions(+), 45 deletions(-) create mode 100644 reboot/examples/chat-room/backend/tests/chat_room.feature diff --git a/documentation/docs/learn_more/testing.md b/documentation/docs/learn_more/testing.md index cfc3ad4cf..3d77e757f 100644 --- a/documentation/docs/learn_more/testing.md +++ b/documentation/docs/learn_more/testing.md @@ -11,10 +11,6 @@ To write a test, you can use the `reboot.aio.tests.Reboot` class. This allows you to start your servicer, create a context, and call the method you want to test. - - - ```py async def asyncSetUp(self) -> None: self.rbt = Reboot() @@ -48,8 +44,6 @@ async def test_chat_room(self) -> None: ) ``` - - #### Setting Secrets Some servicers may use [secrets](/learn_more/secrets) for connecting diff --git a/reboot/examples/chat-room/.tests/test.sh b/reboot/examples/chat-room/.tests/test.sh index edffc8574..87ee316cf 100755 --- a/reboot/examples/chat-room/.tests/test.sh +++ b/reboot/examples/chat-room/.tests/test.sh @@ -23,7 +23,7 @@ check_lines_in_file() { if [ -n "$REBOOT_WHL_FILE" ]; then # Install the `reboot` package from the specified path explicitly, over- # writing the version from `pyproject.toml`. - uv add --no-sync "${SANDBOX_ROOT}$REBOOT_WHL_FILE" + uv add --no-sync "reboot[pytest-bdd] @ ${SANDBOX_ROOT}$REBOOT_WHL_FILE" fi # Force a fresh virtualenv. A pre-existing `.venv/` (e.g., carried diff --git a/reboot/examples/chat-room/backend/tests/chat_room.feature b/reboot/examples/chat-room/backend/tests/chat_room.feature new file mode 100644 index 000000000..0dd6c2da0 --- /dev/null +++ b/reboot/examples/chat-room/backend/tests/chat_room.feature @@ -0,0 +1,13 @@ +Feature: Chat room + + Background: + Given the application is up + And the user is unauthenticated + + Scenario: Messages record in order + When the `ChatRoom` for "testing-chat-room" gets a `send` with `message="Hello, World"` + Then `messages` on the `ChatRoom` for "testing-chat-room" has `messages=["Hello, World"]` + When the `ChatRoom` for "testing-chat-room" gets a `send` with `message="Hello, Reboot!"` + And the `ChatRoom` for "testing-chat-room" gets a `send` with `message="Hello, Peace of Mind!"` + Then `messages` on the `ChatRoom` for "testing-chat-room" has `messages=["Hello, World", "Hello, Reboot!", "Hello, Peace of Mind!"]` + And `messages` on the `ChatRoom` for "testing-chat-room" has `messages` of length 3 and `messages` containing "Hello, Reboot!" diff --git a/reboot/examples/chat-room/backend/tests/chat_room_servicer_test.py b/reboot/examples/chat-room/backend/tests/chat_room_servicer_test.py index aa53c1e81..fabd751fc 100644 --- a/reboot/examples/chat-room/backend/tests/chat_room_servicer_test.py +++ b/reboot/examples/chat-room/backend/tests/chat_room_servicer_test.py @@ -1,39 +1,15 @@ -import unittest -from chat_room.v1.chat_room_rbt import ChatRoom +"""The chat room's tests: the Gherkin scenarios in +`chat_room.feature`.""" + +import pytest from chat_room_servicer import ChatRoomServicer from reboot.aio.applications import Application -from reboot.aio.tests import Reboot - - -class TestHello(unittest.IsolatedAsyncioTestCase): - - async def asyncSetUp(self) -> None: - self.rbt = Reboot() - await self.rbt.start() - - async def asyncTearDown(self) -> None: - await self.rbt.stop() - - async def test_chat_room(self) -> None: - await self.rbt.up(Application(servicers=[ChatRoomServicer])) - - context = self.rbt.create_external_context(name=f"test-{self.id()}") +from reboot.bdd import scenarios - chat_room = ChatRoom.ref("testing-chat-room") - await chat_room.send(context, message="Hello, World") +@pytest.fixture +def application() -> Application: + return Application(servicers=[ChatRoomServicer]) - response: ChatRoom.MessagesResponse = await chat_room.messages(context) - self.assertEqual(response.messages, ["Hello, World"]) - await chat_room.send(context, message="Hello, Reboot!") - await chat_room.send(context, message="Hello, Peace of Mind!") - response = await chat_room.messages(context) - self.assertEqual( - response.messages, - [ - "Hello, World", - "Hello, Reboot!", - "Hello, Peace of Mind!", - ], - ) +scenarios('chat_room.feature') diff --git a/reboot/examples/chat-room/pyproject.toml b/reboot/examples/chat-room/pyproject.toml index 96475b3c9..4f6ed4c13 100644 --- a/reboot/examples/chat-room/pyproject.toml +++ b/reboot/examples/chat-room/pyproject.toml @@ -3,7 +3,7 @@ name = "chat-room" version = "0.1.0" requires-python = ">= 3.10" dependencies = [ - "reboot==1.4.1", + "reboot[pytest-bdd]==1.4.1", ] [dependency-groups] diff --git a/reboot/examples/chat-room/uv.lock b/reboot/examples/chat-room/uv.lock index a4c3e5a73..553d679ee 100644 --- a/reboot/examples/chat-room/uv.lock +++ b/reboot/examples/chat-room/uv.lock @@ -366,7 +366,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "reboot", specifier = "==1.4.1" }] +requires-dist = [{ name = "reboot", extras = ["pytest-bdd"], specifier = "==1.4.1" }] [package.metadata.requires-dev] dev = [ @@ -1799,7 +1799,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1810,9 +1810,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] From cd5929cfaea681ab92bbfdc50e87c0f716878532 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 22:20:06 +0000 Subject: [PATCH 18/42] Write the hello-constructors example's tests in Gherkin 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- .../examples/monorepo/.tests/all_pytests.sh | 2 +- .../backend/tests/hello.feature | 10 +++++ .../backend/tests/hello_servicer_test.py | 38 ++++--------------- reboot/examples/monorepo/pyproject.toml | 2 +- reboot/examples/monorepo/uv.lock | 2 +- 5 files changed, 21 insertions(+), 33 deletions(-) create mode 100644 reboot/examples/monorepo/hello-constructors/backend/tests/hello.feature diff --git a/reboot/examples/monorepo/.tests/all_pytests.sh b/reboot/examples/monorepo/.tests/all_pytests.sh index 7a4f59b28..e62d1d9b5 100755 --- a/reboot/examples/monorepo/.tests/all_pytests.sh +++ b/reboot/examples/monorepo/.tests/all_pytests.sh @@ -36,7 +36,7 @@ function runPyTest () { # Install the `reboot` package from the specified path explicitly, over- # writing the version from `pyproject.toml`. -uv add --no-sync "${SANDBOX_ROOT}$REBOOT_WHL_FILE" +uv add --no-sync "reboot[pytest-bdd] @ ${SANDBOX_ROOT}$REBOOT_WHL_FILE" # Force a fresh virtualenv. A pre-existing `.venv/` (e.g., carried # over from a pre-baked image, or copied between containers/host diff --git a/reboot/examples/monorepo/hello-constructors/backend/tests/hello.feature b/reboot/examples/monorepo/hello-constructors/backend/tests/hello.feature new file mode 100644 index 000000000..38e5cad03 --- /dev/null +++ b/reboot/examples/monorepo/hello-constructors/backend/tests/hello.feature @@ -0,0 +1,10 @@ +Feature: Hello with a factory + + Background: + Given the application is up + And the user is unauthenticated + + Scenario: Messages record from creation onward + Given a `Hello` for "greetings" gets created via `create` with `initial_message="first message"` + When the `Hello` for "greetings" gets a `send` with `message="second message"` + Then `messages` on the `Hello` for "greetings" has `messages=["first message", "second message"]` diff --git a/reboot/examples/monorepo/hello-constructors/backend/tests/hello_servicer_test.py b/reboot/examples/monorepo/hello-constructors/backend/tests/hello_servicer_test.py index c9829660b..b851fe3d5 100644 --- a/reboot/examples/monorepo/hello-constructors/backend/tests/hello_servicer_test.py +++ b/reboot/examples/monorepo/hello-constructors/backend/tests/hello_servicer_test.py @@ -1,36 +1,14 @@ -import unittest -from hello_constructors.v1.hello_rbt import Hello +"""Hello's tests: the Gherkin scenarios in `hello.feature`.""" + +import pytest from hello_servicer import HelloServicer from reboot.aio.applications import Application -from reboot.aio.tests import Reboot - - -class TestHello(unittest.IsolatedAsyncioTestCase): - - async def asyncSetUp(self) -> None: - self.rbt = Reboot() - await self.rbt.start() - - async def asyncTearDown(self) -> None: - await self.rbt.stop() - - async def test_hello_constructors(self) -> None: - await self.rbt.up(Application(servicers=[HelloServicer])) +from reboot.bdd import scenarios - context = self.rbt.create_external_context(name=f"test-{self.id()}") - # Create the state machine by calling its constructor. The fact that the - # state machine _has_ a constructor means that this step is required - # before other methods can be called on it. - hello, _ = await Hello.create(context, initial_message="first message") +@pytest.fixture +def application() -> Application: + return Application(servicers=[HelloServicer]) - # Send another message. - await hello.send(context, message="second message") - messages_response = await hello.messages(context) - self.assertEqual( - messages_response.messages, [ - "first message", - "second message", - ] - ) +scenarios('hello.feature') diff --git a/reboot/examples/monorepo/pyproject.toml b/reboot/examples/monorepo/pyproject.toml index 754f13f4a..8fd12e1fd 100644 --- a/reboot/examples/monorepo/pyproject.toml +++ b/reboot/examples/monorepo/pyproject.toml @@ -3,7 +3,7 @@ name = "monorepo" version = "0.1.0" requires-python = ">= 3.10" dependencies = [ - "reboot==1.4.1", + "reboot[pytest-bdd]==1.4.1", ] [dependency-groups] diff --git a/reboot/examples/monorepo/uv.lock b/reboot/examples/monorepo/uv.lock index 46930720e..07401afd3 100644 --- a/reboot/examples/monorepo/uv.lock +++ b/reboot/examples/monorepo/uv.lock @@ -1067,7 +1067,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "reboot", specifier = "==1.4.1" }] +requires-dist = [{ name = "reboot", extras = ["pytest-bdd"], specifier = "==1.4.1" }] [package.metadata.requires-dev] dev = [ From 9c79005b994ad3e54299cd6d67244d23027c64e8 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 22:27:31 +0000 Subject: [PATCH 19/42] Write the bank example's tests in Gherkin 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- documentation/docs/learn_more/errors.mdx | 6 - documentation/docs/learn_more/tasks.mdx | 6 - .../bank/backend/tests/account.feature | 22 ++++ .../backend/tests/account_servicer_test.py | 108 ++++-------------- .../monorepo/bank/backend/tests/bank.feature | 23 ++++ .../bank/backend/tests/bank_servicer_test.py | 84 ++------------ 6 files changed, 73 insertions(+), 176 deletions(-) create mode 100644 reboot/examples/monorepo/bank/backend/tests/account.feature create mode 100644 reboot/examples/monorepo/bank/backend/tests/bank.feature diff --git a/documentation/docs/learn_more/errors.mdx b/documentation/docs/learn_more/errors.mdx index be449f0c2..eeca49ce2 100644 --- a/documentation/docs/learn_more/errors.mdx +++ b/documentation/docs/learn_more/errors.mdx @@ -215,10 +215,6 @@ Here's an example of how the `OverdraftError` can be caught in a type-safe way: - - - ```py try: await account.withdraw(context, amount=65) @@ -232,8 +228,6 @@ except Account.WithdrawAborted as aborted: ) raise ``` - - - - ```py response = await Account.WelcomeEmailTask.retrieve( context, task_id=welcome_email_task_id, ) ``` - - ```ts diff --git a/reboot/examples/monorepo/bank/backend/tests/account.feature b/reboot/examples/monorepo/bank/backend/tests/account.feature new file mode 100644 index 000000000..b32cd7700 --- /dev/null +++ b/reboot/examples/monorepo/bank/backend/tests/account.feature @@ -0,0 +1,22 @@ +Feature: Accounts + + Background: + Given the application is up + And the user is unauthenticated + + Scenario: Depositing and withdrawing move the balance + Given an `Account` for "alice" gets created via `open` with `customer_name="Alice"` + Then `balance` on the `Account` for "alice" has `balance=0` + When the `Account` for "alice" gets a `deposit` with `amount=100` + Then `balance` on the `Account` for "alice" has `balance=100` + When the `Account` for "alice" gets a `withdraw` with `amount=60` + Then `balance` on the `Account` for "alice" has `balance=40` + When the `Account` for "alice" attempts a `withdraw` with `amount=65` + Then the attempt aborts with `OverdraftError` with `amount=25` + And `balance` on the `Account` for "alice" has `balance=40` + + Scenario: Opening sends a welcome email + Given an `Account` for "bob" gets created via `open` with `customer_name="Bob"` + And the resulting `welcome_email_task_id` is saved as `welcome_email_task_id` + Then the `welcome_email` task with id "${welcome_email_task_id}" of the `Account` completes within 30 seconds + And the welcome email was sent diff --git a/reboot/examples/monorepo/bank/backend/tests/account_servicer_test.py b/reboot/examples/monorepo/bank/backend/tests/account_servicer_test.py index ddb89acc2..12d609489 100644 --- a/reboot/examples/monorepo/bank/backend/tests/account_servicer_test.py +++ b/reboot/examples/monorepo/bank/backend/tests/account_servicer_test.py @@ -1,99 +1,31 @@ -import unittest +"""The account's tests: the Gherkin scenarios in `account.feature`.""" + +import pytest from account_servicer import AccountServicer -from bank.v1.account_rbt import Account, BalanceResponse -from bank.v1.errors_pb2 import OverdraftError from reboot.aio.applications import Application -from reboot.aio.tests import Reboot +from reboot.bdd import scenarios, then +from typing import Iterator from unittest import mock -def report_error_to_user(error_message: str) -> None: - # This is a dummy function for use in documentation code snippets. - pass - - -class TestAccount(unittest.IsolatedAsyncioTestCase): - - async def asyncSetUp(self) -> None: - self.rbt = Reboot() - await self.rbt.start() - - async def asyncTearDown(self) -> None: - await self.rbt.stop() - - async def test_basics(self) -> None: - await self.rbt.up(Application(servicers=[AccountServicer])) - context = self.rbt.create_external_context(name=f"test-{self.id()}") - - # Create the state machine by calling its constructor. The fact that the - # state machine _has_ a constructor means that this step is required - # before other methods can be called on it. - account, _ = await Account.open(context, customer_name="Alice") - - # We can now call methods on the state machine. It should have a balance - # of 0. - response: BalanceResponse = await account.balance(context) - self.assertEqual(response.balance, 0) - - # When we deposit money, the balance should go up. - await account.deposit(context, amount=100) - response = await account.balance(context) - self.assertEqual(response.balance, 100) - - # When we withdraw money, the balance should go down. - await account.withdraw(context, amount=60) - response = await account.balance(context) - self.assertEqual(response.balance, 40) - - # When we withdraw too much money, we should get an error. - # Use a helper function here to get a code snippet for use in docs. - async def withdraw(): - try: - await account.withdraw(context, amount=65) - except Account.WithdrawAborted as aborted: - match aborted.error: - case OverdraftError(amount=amount): - report_error_to_user( - 'Your withdrawal could not be processed due to ' - 'insufficient funds. Your account balance is less ' - f'than the requested amount by {amount} dollars.' - ) - raise +@pytest.fixture +def application() -> Application: + return Application(servicers=[AccountServicer]) - with self.assertRaises(Account.WithdrawAborted) as aborted: - await withdraw() - self.assertTrue(isinstance(aborted.exception.error, OverdraftError)) - self.assertEqual(aborted.exception.error.amount, 25) - # ... and the balance shouldn't have changed. - response = await account.balance(context) - self.assertEqual(response.balance, 40) +# The welcome email goes through `send_email`, mocked so scenarios +# can observe it. +@pytest.fixture(autouse=True) +def send_email() -> Iterator[mock.AsyncMock]: + with mock.patch('account_servicer.send_email') as mocked: + yield mocked - @mock.patch("account_servicer.send_email") - async def test_send_welcome_email(self, mock_send_email) -> None: - await self.rbt.up( - Application(servicers=[AccountServicer]), - ) - context = self.rbt.create_external_context(name=f"test-{self.id()}") - # When we open an account, we expect the user to receive a welcome - # email. - account, open_response = await Account.open( - context, - customer_name="Alice", - ) +@then('the welcome email was sent') +def _the_welcome_email_was_sent(send_email: mock.AsyncMock) -> None: + # Reboot re-runs methods twice in development mode to validate + # that they are idempotent, so the email sends twice. + assert send_email.call_count == 2 - welcome_email_task_id = open_response.welcome_email_task_id - # Wait for the email task to run. - response = await Account.WelcomeEmailTask.retrieve( - context, - task_id=welcome_email_task_id, - ) - # We are only capturing the response for docs purposes, and - # need to explicitly delete it to avoid linting errors. - del response - # We can expect two attempts to send the email, because Reboot always - # re-runs methods twice in development mode in order to validate that - # calls are idempotent. - self.assertEqual(mock_send_email.call_count, 2) +scenarios('account.feature') diff --git a/reboot/examples/monorepo/bank/backend/tests/bank.feature b/reboot/examples/monorepo/bank/backend/tests/bank.feature new file mode 100644 index 000000000..8e49b23f7 --- /dev/null +++ b/reboot/examples/monorepo/bank/backend/tests/bank.feature @@ -0,0 +1,23 @@ +Feature: Bank + + Background: + Given the application is up + And the user is unauthenticated + + Scenario: Signing up opens an account + When the `Bank` for "my-bank" gets a `sign_up` with `customer_name="Alice"` + And the resulting `account_id` is saved as `alice_account_id` + Then `balance` on the `Account` for "${alice_account_id}" has `balance=0` + + Scenario: Transfers move money between accounts + Given the `Bank` for "my-bank" gets a `sign_up` with `customer_name="Alice"` + And the resulting `account_id` is saved as `alice_account_id` + And the `Bank` for "my-bank" gets a `sign_up` with `customer_name="Bob"` + And the resulting `account_id` is saved as `bob_account_id` + When the `Account` for "${alice_account_id}" gets a `deposit` with `amount=100` + Then `balance` on the `Account` for "${alice_account_id}" has `balance=100` + When the `Bank` for "my-bank" gets a `transfer` with `from_account_id=${alice_account_id}` and `to_account_id=${bob_account_id}` and `amount=40` + Then `balance` on the `Account` for "${alice_account_id}" has `balance=60` + And `balance` on the `Account` for "${bob_account_id}" has `balance=40` + When the `Bank` for "my-bank" attempts a `transfer` with `from_account_id=${bob_account_id}` and `to_account_id=${alice_account_id}` and `amount=50` + Then the attempt aborts with `OverdraftError` with `amount=10` diff --git a/reboot/examples/monorepo/bank/backend/tests/bank_servicer_test.py b/reboot/examples/monorepo/bank/backend/tests/bank_servicer_test.py index b37ed904a..b8446f62f 100644 --- a/reboot/examples/monorepo/bank/backend/tests/bank_servicer_test.py +++ b/reboot/examples/monorepo/bank/backend/tests/bank_servicer_test.py @@ -1,83 +1,15 @@ -import unittest +"""The bank's tests: the Gherkin scenarios in `bank.feature`.""" + +import pytest from account_servicer import AccountServicer -from bank.v1.account_rbt import Account, BalanceResponse -from bank.v1.bank_rbt import Bank, SignUpResponse -from bank.v1.errors_pb2 import OverdraftError from bank_servicer import BankServicer from reboot.aio.applications import Application -from reboot.aio.tests import Reboot - - -class TestAccount(unittest.IsolatedAsyncioTestCase): - - async def asyncSetUp(self) -> None: - self.rbt = Reboot() - await self.rbt.start() - - async def asyncTearDown(self) -> None: - await self.rbt.stop() - - async def test_signup(self) -> None: - await self.rbt.up( - Application(servicers=[BankServicer, AccountServicer]) - ) - context = self.rbt.create_external_context(name=f"test-{self.id()}") - bank = Bank.ref("my-bank") - - # The Bank state machine doesn't have a constructor, so we can simply - # start calling methods on it. - response: SignUpResponse = await bank.sign_up( - context, - customer_name="Alice", - ) - - # SignUp will have created an Account we can call. - account = Account.ref(response.account_id) - response = await account.balance(context) - self.assertEqual(response.balance, 0) - - async def test_transfer(self): - await self.rbt.up( - Application(servicers=[BankServicer, AccountServicer]) - ) - context = self.rbt.create_external_context(name=f"test-{self.id()}") - bank = Bank.ref("my-bank") +from reboot.bdd import scenarios - alice: SignUpResponse = await bank.sign_up( - context, - customer_name="Alice", - ) - alice_account = Account.ref(alice.account_id) - bob: SignUpResponse = await bank.sign_up( - context, - customer_name="Bob", - ) - bob_account = Account.ref(bob.account_id) - # Alice deposits some money. - await alice_account.deposit(context, amount=100) - response: BalanceResponse = await alice_account.balance(context) - self.assertEqual(response.balance, 100) +@pytest.fixture +def application() -> Application: + return Application(servicers=[BankServicer, AccountServicer]) - # Alice transfers some money to Bob. - await bank.transfer( - context, - from_account_id=alice.account_id, - to_account_id=bob.account_id, - amount=40, - ) - response = await alice_account.balance(context) - self.assertEqual(response.balance, 60) - response = await bob_account.balance(context) - self.assertEqual(response.balance, 40) - # Bob tries to transfer too much money back to Alice. - with self.assertRaises(Bank.TransferAborted) as aborted: - await bank.transfer( - context, - from_account_id=bob.account_id, - to_account_id=alice.account_id, - amount=50, - ) - self.assertTrue(isinstance(aborted.exception.error, OverdraftError)) - self.assertEqual(aborted.exception.error.amount, 10) +scenarios('bank.feature') From a3cede423d476fcba278ebc81448e1d1bd535eee Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 23:01:37 +0000 Subject: [PATCH 20/42] Write the bank-pydantic example's tests in Gherkin 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/examples/bank-pydantic/.tests/test.sh | 2 +- .../bank-pydantic/backend/tests/bank.feature | 32 ++++ .../backend/tests/full_bank_test.py | 177 ++---------------- reboot/examples/bank-pydantic/pyproject.toml | 2 +- reboot/examples/bank-pydantic/uv.lock | 8 +- 5 files changed, 58 insertions(+), 163 deletions(-) create mode 100644 reboot/examples/bank-pydantic/backend/tests/bank.feature diff --git a/reboot/examples/bank-pydantic/.tests/test.sh b/reboot/examples/bank-pydantic/.tests/test.sh index edffc8574..87ee316cf 100755 --- a/reboot/examples/bank-pydantic/.tests/test.sh +++ b/reboot/examples/bank-pydantic/.tests/test.sh @@ -23,7 +23,7 @@ check_lines_in_file() { if [ -n "$REBOOT_WHL_FILE" ]; then # Install the `reboot` package from the specified path explicitly, over- # writing the version from `pyproject.toml`. - uv add --no-sync "${SANDBOX_ROOT}$REBOOT_WHL_FILE" + uv add --no-sync "reboot[pytest-bdd] @ ${SANDBOX_ROOT}$REBOOT_WHL_FILE" fi # Force a fresh virtualenv. A pre-existing `.venv/` (e.g., carried diff --git a/reboot/examples/bank-pydantic/backend/tests/bank.feature b/reboot/examples/bank-pydantic/backend/tests/bank.feature new file mode 100644 index 000000000..a5ac2d88c --- /dev/null +++ b/reboot/examples/bank-pydantic/backend/tests/bank.feature @@ -0,0 +1,32 @@ +Feature: Bank + + Background: + Given the application is up + And the user is unauthenticated + + Scenario: Transfers move money between accounts + Given a `Bank` for "test-bank" gets created via `create` + When the `Bank` for "test-bank" gets a `sign_up` with `customer_id="test@reboot.dev"` + And the `Customer` for "test@reboot.dev" gets a `open_account` with `initial_deposit=1000.0` + And the resulting `account_id` is saved as `first_account_id` + And the `Bank` for "test-bank" gets a `sign_up` with `customer_id="test2@reboot.dev"` + And the `Customer` for "test2@reboot.dev" gets a `open_account` with `initial_deposit=0.0` + And the resulting `account_id` is saved as `second_account_id` + And the `Bank` for "test-bank" gets a `transfer` with `from_account_id=${first_account_id}` and `to_account_id=${second_account_id}` and `amount=250.0` + Then `balance` on the `Account` for "${first_account_id}" has `amount=750.0` + And `balance` on the `Account` for "${second_account_id}" has `amount=250.0` + And `all_customer_ids` on the `Bank` for "test-bank" has `customer_ids` of length 2 and `customer_ids` containing "test@reboot.dev" and `customer_ids` containing "test2@reboot.dev" + And `account_balances` on the `Bank` for "test-bank" has `balances` of length 2 and `balances[0].customer_id="test@reboot.dev"` and `balances[0].accounts` of length 1 and `balances[0].accounts[0].balance=750.0` and `balances[1].customer_id="test2@reboot.dev"` and `balances[1].accounts` of length 1 and `balances[1].accounts[0].balance=250.0` + + Scenario: Overdrafts are refused + Given an `Account` for "overdraft-account" gets created via `open` + When the `Account` for "overdraft-account" attempts a `withdraw` with `amount=50.50` + Then the attempt aborts with `OverdraftError` with `amount=50.50` + + Scenario: Spawned deposits and reads complete + Given an `Account` for "spawning-account" gets created via `open` + When the `Account` for "spawning-account" gets a `deposit` with `amount=10.0` spawned with its task id saved as `deposit_task_id` + Then the `deposit` task with id "${deposit_task_id}" of the `Account` completes within 30 seconds + When the `Account` for "spawning-account" gets a `balance` spawned with its task id saved as `balance_task_id` + Then the `balance` task with id "${balance_task_id}" of the `Account` completes within 30 seconds + And the result has `amount=10.0` diff --git a/reboot/examples/bank-pydantic/backend/tests/full_bank_test.py b/reboot/examples/bank-pydantic/backend/tests/full_bank_test.py index 04e39db22..02060709b 100644 --- a/reboot/examples/bank-pydantic/backend/tests/full_bank_test.py +++ b/reboot/examples/bank-pydantic/backend/tests/full_bank_test.py @@ -1,27 +1,24 @@ -import unittest +"""The bank's tests: the Gherkin scenarios in `bank.feature`. + +The scenarios run against servicer subclasses that wire up +authorizers, showing how a test exercises authorization, and that +disable the account's scheduled interest so balances stay stable. +""" + +import pytest from account_servicer import AccountServicer -from bank.v1.account import BalanceResponse, OverdraftError from bank.v1.account_rbt import Account -from bank.v1.bank import ( - AccountBalancesResponse, - AllCustomerIdsResponse, - SignUpRequest, - TransferRequest, -) from bank.v1.bank_rbt import Bank -from bank.v1.customer_rbt import Customer from bank_servicer import BankServicer from customer_servicer import CustomerServicer from rbt.v1alpha1 import errors_pb2 from reboot.aio.applications import Application from reboot.aio.auth.authorizers import allow, allow_if from reboot.aio.contexts import ReaderContext, WriterContext -from reboot.aio.tests import Reboot +from reboot.bdd import scenarios from reboot.std.collections.v1.sorted_map import sorted_map_library from typing import Optional -BANK_ID = 'test-bank' - class BankServicerWithAuthorizer(BankServicer): @@ -129,150 +126,16 @@ async def interest( pass -class TestBank(unittest.IsolatedAsyncioTestCase): - - async def asyncSetUp(self) -> None: - self.rbt = Reboot() - await self.rbt.start() - - async def asyncTearDown(self) -> None: - await self.rbt.stop() - - async def test_transfer(self) -> None: - await self.rbt.up( - Application( - servicers=[ - BankServicerWithAuthorizer, - AccountServicerWithNoInterestAndAuthorizer, - CustomerServicer, - ], - libraries=[sorted_map_library()], - ) - ) - context = self.rbt.create_external_context(name=f"test-{self.id()}") - bank, response = await Bank.create(context, BANK_ID) - - # Assert that the constructor returns 'None' as described in - # Pydantic schema. - assert response is None - - CUSTOMER_ID_1 = "test@reboot.dev" - CUSTOMER_ID_2 = "test2@reboot.dev" - - await bank.sign_up( - context, - # Show Pydantic model request. - SignUpRequest( - customer_id=CUSTOMER_ID_1, - ), - ) - - open_account_response_1 = await Customer.ref( - CUSTOMER_ID_1 - ).open_account(context, initial_deposit=1000.0) - - await bank.sign_up( - context, - # Show field name kwargs request. - customer_id=CUSTOMER_ID_2, - ) - - all_customer_ids_response = await bank.all_customer_ids(context) - - assert isinstance(all_customer_ids_response, AllCustomerIdsResponse) - - open_account_response_2 = await Customer.ref( - CUSTOMER_ID_2 - ).open_account(context, initial_deposit=0.0) - - await bank.transfer( - context, - # Show Pydantic model request. - TransferRequest( - from_account_id=open_account_response_1.account_id, - to_account_id=open_account_response_2.account_id, - amount=250.0, - ) - ) - - account_balances = await bank.account_balances(context) - - # Assert that the response is a Pydantic model as described in - # the Pydantic schema. - assert isinstance(account_balances, AccountBalancesResponse) - - for account_balance in account_balances.balances: - if account_balance.customer_id == CUSTOMER_ID_1: - self.assertEqual(len(account_balance.accounts), 1) - self.assertEqual(account_balance.accounts[0].balance, 750.0) - elif account_balance.customer_id == CUSTOMER_ID_2: - self.assertEqual(len(account_balance.accounts), 1) - self.assertEqual(account_balance.accounts[0].balance, 250.0) - else: - self.fail( - f"Unexpected customer ID: {account_balance.customer_id}" - ) - - # Also test balances from the Account servicer. - account_1 = Account.ref(open_account_response_1.account_id) - account_2 = Account.ref(open_account_response_2.account_id) - - balance_response_1 = await account_1.balance(context) - assert isinstance(balance_response_1, BalanceResponse) - self.assertEqual(balance_response_1.amount, 750.0) - - balance_response_2 = await account_2.balance(context) - assert isinstance(balance_response_2, BalanceResponse) - self.assertEqual(balance_response_2.amount, 250.0) - - async def test_overdraft(self) -> None: - await self.rbt.up( - Application( - servicers=[ - BankServicerWithAuthorizer, - AccountServicerWithNoInterestAndAuthorizer, - CustomerServicer, - ], - libraries=[sorted_map_library()], - ) - ) - context = self.rbt.create_external_context(name=f"test-{self.id()}") - await Bank.create(context, BANK_ID) - - ACCOUNT_ID = "test-overdraft-account" - account, _ = await Account.open(context, ACCOUNT_ID) - try: - await account.withdraw(context, amount=50.50) - raise Exception("Expected `OverdraftError` to be thrown") - except Account.WithdrawAborted as aborted: - assert isinstance(aborted.error, OverdraftError) - self.assertEqual(aborted.error.amount, 50.50) - - async def test_tasks(self) -> None: - await self.rbt.up( - Application( - servicers=[ - BankServicerWithAuthorizer, - AccountServicerWithNoInterestAndAuthorizer, - CustomerServicer, - ], - libraries=[sorted_map_library()], - ) - ) - context = self.rbt.create_external_context(name=f"test-{self.id()}") - await Bank.create(context, BANK_ID) - - ACCOUNT_ID = "test-overdraft-account" - - account, _ = await Account.open(context, ACCOUNT_ID) - - task = await account.spawn().deposit(context, amount=10.0) - - await task - - balance_task = await account.spawn().balance(context) - balance_response = await balance_task +@pytest.fixture +def application() -> Application: + return Application( + servicers=[ + BankServicerWithAuthorizer, + AccountServicerWithNoInterestAndAuthorizer, + CustomerServicer, + ], + libraries=[sorted_map_library()], + ) - assert isinstance(balance_response, BalanceResponse) - self.assertEqual(balance_response.amount, 10.0) +scenarios('bank.feature') diff --git a/reboot/examples/bank-pydantic/pyproject.toml b/reboot/examples/bank-pydantic/pyproject.toml index 872cd35bc..4e4efafff 100644 --- a/reboot/examples/bank-pydantic/pyproject.toml +++ b/reboot/examples/bank-pydantic/pyproject.toml @@ -3,7 +3,7 @@ name = "bank-pydantic" version = "0.1.0" requires-python = ">= 3.10" dependencies = [ - "reboot==1.4.1", + "reboot[pytest-bdd]==1.4.1", ] [dependency-groups] diff --git a/reboot/examples/bank-pydantic/uv.lock b/reboot/examples/bank-pydantic/uv.lock index af9eaf728..32ba9c957 100644 --- a/reboot/examples/bank-pydantic/uv.lock +++ b/reboot/examples/bank-pydantic/uv.lock @@ -214,7 +214,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "reboot", specifier = "==1.4.1" }] +requires-dist = [{ name = "reboot", extras = ["pytest-bdd"], specifier = "==1.4.1" }] [package.metadata.requires-dev] dev = [ @@ -1799,7 +1799,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1810,9 +1810,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] From 9baa7eb1d342f66fbe6e9966477485c5472efc8e Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 23:12:28 +0000 Subject: [PATCH 21/42] Write the chick-potle example's tests in Gherkin 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/examples/chick-potle/.tests/test.sh | 2 +- .../chick-potle/backend/tests/food.feature | 43 +++++ .../chick-potle/backend/tests/food_test.py | 161 ++---------------- reboot/examples/chick-potle/pyproject.toml | 2 +- reboot/examples/chick-potle/uv.lock | 8 +- 5 files changed, 63 insertions(+), 153 deletions(-) create mode 100644 reboot/examples/chick-potle/backend/tests/food.feature diff --git a/reboot/examples/chick-potle/.tests/test.sh b/reboot/examples/chick-potle/.tests/test.sh index 35fa002c9..585a75c0b 100755 --- a/reboot/examples/chick-potle/.tests/test.sh +++ b/reboot/examples/chick-potle/.tests/test.sh @@ -23,7 +23,7 @@ check_lines_in_file() { if [ -n "$REBOOT_WHL_FILE" ]; then # Install the `reboot` package from the specified path # explicitly, overwriting the version from `pyproject.toml`. - uv add --no-sync "${SANDBOX_ROOT}$REBOOT_WHL_FILE" + uv add --no-sync "reboot[pytest-bdd] @ ${SANDBOX_ROOT}$REBOOT_WHL_FILE" fi # Force a fresh virtualenv. A pre-existing `.venv/` (e.g., carried diff --git a/reboot/examples/chick-potle/backend/tests/food.feature b/reboot/examples/chick-potle/backend/tests/food.feature new file mode 100644 index 000000000..7ed195b9e --- /dev/null +++ b/reboot/examples/chick-potle/backend/tests/food.feature @@ -0,0 +1,43 @@ +Feature: Food orders + + Background: + Given the application is up + And the authenticated user is "alice" + + Scenario: Starting an order pre-populates the menu with an empty cart + When the `User` for "alice" gets a `start_order` + And the resulting `order_id` is saved as `order_id` + Then `get_menu` on the `FoodOrder` for "${order_id}" has `items` of length 10 and `items[0].name="Chicken Burrito"` and `items[0].category="Burritos"` and `items[0].price_cents=1115` + And `get_cart` on the `FoodOrder` for "${order_id}" has `entries=[]` and `total_cents=0` + + Scenario: Adding the same item twice increments its quantity + Given the `User` for "alice" gets a `start_order` + And the resulting `order_id` is saved as `order_id` + When the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=0` and `quantity=1` + And the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=0` and `quantity=1` + And the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=1` and `quantity=1` + Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length 2 and `entries[0].item_index=0` and `entries[0].quantity=2` and `entries[1].item_index=1` and `entries[1].quantity=1` and `total_cents=3470` + When the `FoodOrder` for "${order_id}" gets a `remove_from_cart` with `item_index=0` + Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length 1 and `entries[0].item_index=1` and `total_cents=1240` + + Scenario: A quantity of zero means one + Given the `User` for "alice" gets a `start_order` + And the resulting `order_id` is saved as `order_id` + When the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=0` and `quantity=0` + Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length 1 and `entries[0].quantity=1` + + Scenario: Out-of-range menu indexes are refused + Given the `User` for "alice" gets a `start_order` + And the resulting `order_id` is saved as `order_id` + When the `FoodOrder` for "${order_id}" attempts a `add_to_cart` with `item_index=10` and `quantity=1` + Then the attempt aborts with `Unknown` + When the `FoodOrder` for "${order_id}" attempts a `add_to_cart` with `item_index=-1` and `quantity=1` + Then the attempt aborts with `Unknown` + + Scenario: Another user cannot touch the order + Given the `User` for "alice" gets a `start_order` + And the resulting `order_id` is saved as `order_id` + When the authenticated user is "bob" + Then `get_cart` on the `FoodOrder` for "${order_id}" aborts with `PermissionDenied` + When the `FoodOrder` for "${order_id}" attempts a `add_to_cart` with `item_index=0` and `quantity=1` + Then the attempt aborts with `PermissionDenied` diff --git a/reboot/examples/chick-potle/backend/tests/food_test.py b/reboot/examples/chick-potle/backend/tests/food_test.py index fe02c1df9..15aa4c63b 100644 --- a/reboot/examples/chick-potle/backend/tests/food_test.py +++ b/reboot/examples/chick-potle/backend/tests/food_test.py @@ -1,154 +1,21 @@ -"""Tests for the chick-potle backend. +"""The chick-potle tests: the Gherkin scenarios in `food.feature`. -Covers `User.start_order` and `FoodOrder` CRUD via direct -Reboot calls.""" -import unittest -from ai_chat_food.v1.food_rbt import FoodOrder, User -from reboot.aio.aborted import Aborted +The scenarios register the real servicers with their real +authorizers and say who the authenticated user is, so the +authorization rules run in every scenario, exactly as in +production; minting the user's token also constructs their `User` +state, the way a production sign-in does. +""" + +import pytest from reboot.aio.applications import Application -from reboot.aio.tests import Reboot +from reboot.bdd import scenarios from servicers.food import FoodOrderServicer, UserServicer -# The tests register the real servicers, with their real authorizers, -# and impersonate an authenticated user by minting a bearer token. That -# way the authorization rules are exercised by every test, exactly as in -# production. - -APPLICATION_SERVICERS = [ - UserServicer, - FoodOrderServicer, -] - - -class ServicerTest(unittest.IsolatedAsyncioTestCase): - """Unit tests for `User` and `FoodOrder` servicers.""" - - async def asyncSetUp(self) -> None: - self.rbt = Reboot() - await self.rbt.start() - await self.rbt.up( - Application( - servicers=APPLICATION_SERVICERS, - ), - ) - self.user_id = "alice" - self.context = await self.rbt.create_external_context_as( - name=f"test-{self.id()}", - user_id=self.user_id, - ) - # `User` is an auto-constructed state type: in - # production the framework calls `_authenticated` for the - # authenticated user when their token is minted. Tests can - # trigger it explicitly here. - await UserServicer._authenticated( - self.context, - state_id=self.user_id, - ) - - async def asyncTearDown(self) -> None: - await self.rbt.stop() - - async def test_start_order_returns_food_order_id(self) -> None: - """`User.start_order` creates a `FoodOrder` whose - menu is pre-populated and whose cart is empty.""" - user = User.ref(self.user_id) - response = await user.start_order(self.context) - self.assertTrue(response.order_id) - - order = FoodOrder.ref(response.order_id) - menu_response = await order.get_menu(self.context) - # The menu is constant, populated by the servicer; we - # just check that it isn't empty and that the items - # have the fields the UI expects. - self.assertGreater(len(menu_response.items), 0) - first = menu_response.items[0] - self.assertTrue(first.name) - self.assertTrue(first.category) - self.assertGreater(first.price_cents, 0) - - cart_response = await order.get_cart(self.context) - self.assertEqual(cart_response.entries, []) - self.assertEqual(cart_response.total_cents, 0) - - async def test_add_and_remove_from_cart(self) -> None: - """Adding the same item twice increments its - quantity instead of creating a duplicate row, and - the cart total reflects menu prices.""" - user = User.ref(self.user_id) - start_response = await user.start_order(self.context) - order = FoodOrder.ref(start_response.order_id) - - menu_response = await order.get_menu(self.context) - first_price = menu_response.items[0].price_cents - second_price = menu_response.items[1].price_cents - - # Add two of item 0 and one of item 1; the second - # add for item 0 should bump quantity, not append. - await order.add_to_cart(self.context, item_index=0, quantity=1) - await order.add_to_cart(self.context, item_index=0, quantity=1) - await order.add_to_cart(self.context, item_index=1, quantity=1) - - cart_response = await order.get_cart(self.context) - self.assertEqual(len(cart_response.entries), 2) - by_index = { - entry.item_index: entry.quantity for entry in cart_response.entries - } - self.assertEqual(by_index[0], 2) - self.assertEqual(by_index[1], 1) - self.assertEqual( - cart_response.total_cents, - 2 * first_price + second_price, - ) - - # Remove item 0 entirely; item 1 should remain. - await order.remove_from_cart(self.context, item_index=0) - cart_response = await order.get_cart(self.context) - self.assertEqual(len(cart_response.entries), 1) - self.assertEqual(cart_response.entries[0].item_index, 1) - self.assertEqual(cart_response.total_cents, second_price) - - async def test_add_to_cart_default_quantity(self) -> None: - """A `quantity` of 0 (the protobuf default) is - treated as 1, matching the AI-friendly contract in - `food.py`.""" - user = User.ref(self.user_id) - start_response = await user.start_order(self.context) - order = FoodOrder.ref(start_response.order_id) - - await order.add_to_cart(self.context, item_index=0, quantity=0) - cart_response = await order.get_cart(self.context) - self.assertEqual(len(cart_response.entries), 1) - self.assertEqual(cart_response.entries[0].quantity, 1) - - async def test_add_to_cart_invalid_index_raises(self) -> None: - """Out-of-range indexes raise `ValueError` rather - than silently corrupting the cart.""" - user = User.ref(self.user_id) - start_response = await user.start_order(self.context) - order = FoodOrder.ref(start_response.order_id) - - menu_response = await order.get_menu(self.context) - too_large = len(menu_response.items) - with self.assertRaises(Exception): - await order.add_to_cart( - self.context, item_index=too_large, quantity=1 - ) - with self.assertRaises(Exception): - await order.add_to_cart(self.context, item_index=-1, quantity=1) +@pytest.fixture +def application() -> Application: + return Application(servicers=[FoodOrderServicer, UserServicer]) - async def test_other_user_cannot_access_order(self) -> None: - """The `FoodOrder` authorizer only admits the user who started the - order; a different authenticated user is denied.""" - user = User.ref(self.user_id) - start_response = await user.start_order(self.context) - order = FoodOrder.ref(start_response.order_id) - other_context = await self.rbt.create_external_context_as( - name=f"other-{self.id()}", - user_id="bob", - ) - with self.assertRaises(Aborted): - await order.get_cart(other_context) - with self.assertRaises(Aborted): - await order.add_to_cart(other_context, item_index=0, quantity=1) +scenarios('food.feature') diff --git a/reboot/examples/chick-potle/pyproject.toml b/reboot/examples/chick-potle/pyproject.toml index a6bbbb4d5..9b7a46784 100644 --- a/reboot/examples/chick-potle/pyproject.toml +++ b/reboot/examples/chick-potle/pyproject.toml @@ -6,7 +6,7 @@ dependencies = [ "httpx>=0.27,<1.0", "uuid7>=0.1.0", "anyio>=4.0.0", - "reboot==1.4.1", + "reboot[pytest-bdd]==1.4.1", ] [dependency-groups] diff --git a/reboot/examples/chick-potle/uv.lock b/reboot/examples/chick-potle/uv.lock index cad4c22fc..7c774404e 100644 --- a/reboot/examples/chick-potle/uv.lock +++ b/reboot/examples/chick-potle/uv.lock @@ -370,7 +370,7 @@ dev = [ requires-dist = [ { name = "anyio", specifier = ">=4.0.0" }, { name = "httpx", specifier = ">=0.27,<1.0" }, - { name = "reboot", specifier = "==1.4.1" }, + { name = "reboot", extras = ["pytest-bdd"], specifier = "==1.4.1" }, { name = "uuid7", specifier = ">=0.1.0" }, ] @@ -1747,7 +1747,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1758,9 +1758,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] From 3bb58ebe56a1d566eeb4d1de4e92bc25e0e23559 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 23:25:07 +0000 Subject: [PATCH 22/42] Write the agent-wiki example's tests in Gherkin 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/examples/agent-wiki/.tests/test.sh | 2 +- .../backend/tests/wiki_crud.feature | 29 ++ .../backend/tests/wiki_crud_test.py | 53 +++ .../backend/tests/wiki_ingest.feature | 16 + .../backend/tests/wiki_ingest_test.py | 134 ++++++ .../agent-wiki/backend/tests/wiki_test.py | 393 ------------------ .../backend/tests/wiki_transcript.feature | 12 + .../backend/tests/wiki_transcript_test.py | 49 +++ reboot/examples/agent-wiki/pyproject.toml | 2 +- reboot/examples/agent-wiki/uv.lock | 8 +- 10 files changed, 299 insertions(+), 399 deletions(-) create mode 100644 reboot/examples/agent-wiki/backend/tests/wiki_crud.feature create mode 100644 reboot/examples/agent-wiki/backend/tests/wiki_crud_test.py create mode 100644 reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature create mode 100644 reboot/examples/agent-wiki/backend/tests/wiki_ingest_test.py delete mode 100644 reboot/examples/agent-wiki/backend/tests/wiki_test.py create mode 100644 reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature create mode 100644 reboot/examples/agent-wiki/backend/tests/wiki_transcript_test.py diff --git a/reboot/examples/agent-wiki/.tests/test.sh b/reboot/examples/agent-wiki/.tests/test.sh index c8207fad9..74cfeec01 100755 --- a/reboot/examples/agent-wiki/.tests/test.sh +++ b/reboot/examples/agent-wiki/.tests/test.sh @@ -23,7 +23,7 @@ check_lines_in_file() { if [ -n "$REBOOT_WHL_FILE" ]; then # Install the `reboot` package from the specified path # explicitly, overwriting the version from `pyproject.toml`. - uv add --no-sync "${SANDBOX_ROOT}$REBOOT_WHL_FILE" + uv add --no-sync "reboot[pytest-bdd] @ ${SANDBOX_ROOT}$REBOOT_WHL_FILE" fi # Force a fresh virtualenv. A pre-existing `.venv/` (e.g., carried diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature b/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature new file mode 100644 index 000000000..f4d8ae11a --- /dev/null +++ b/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature @@ -0,0 +1,29 @@ +Feature: Wiki, page, and transcript CRUD + + Background: + Given the application is up + And the authenticated user is "alice" + + Scenario: A created wiki appears in the user's list + When the `User` for "alice" gets a `create_wiki` with `name="my notes"` and `description="my personal notes"` + And the resulting `wiki_id` is saved as `wiki_id` + Then `list_wikis` on the `User` for "alice" has `wikis` of length 1 and `wikis[0].wiki_id=${wiki_id}` and `wikis[0].name="my notes"` and `wikis[0].description="my personal notes"` + + Scenario: A fresh wiki updates its markdown body + Given the `User` for "alice" gets a `create_wiki` with `name="my notes"` and `description="my personal notes"` + And the resulting `wiki_id` is saved as `wiki_id` + Then `get` on the `Wiki` for "${wiki_id}" has `name="my notes"` and `description="my personal notes"` and `content=""` + When the `Wiki` for "${wiki_id}" gets a `update` with `content="# Hello\n"` + Then `get` on the `Wiki` for "${wiki_id}" has `content="# Hello\n"` + + Scenario: Pages round-trip their title and body + Given a `Page` for "my-page" gets created via `create` with `title="My Page"` and `content="Initial body."` and `owner_id="alice"` + Then `get` on the `Page` for "my-page" has `title="My Page"` and `content="Initial body."` + When the `Page` for "my-page" gets a `update` with `title="Renamed Page"` and `content="New body."` + Then `get` on the `Page` for "my-page" has `title="Renamed Page"` and `content="New body."` + + Scenario: Transcripts round-trip their messages + Given a `Transcript` for "my-transcript" gets created via `create` with `messages=[{role: "user", content: "Hello"}, {role: "assistant", content: "Hi!"}]` and `owner_id="alice"` + Then `get` on the `Transcript` for "my-transcript" has `messages` of length 2 and `messages[0].role="user"` and `messages[0].content="Hello"` and `messages[1].role="assistant"` and `messages[1].content="Hi!"` + When the `Transcript` for "my-transcript" gets a `update` with `messages=[{role: "user", content: "Goodbye"}]` + Then `get` on the `Transcript` for "my-transcript" has `messages` of length 1 and `messages[0].content="Goodbye"` diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_crud_test.py b/reboot/examples/agent-wiki/backend/tests/wiki_crud_test.py new file mode 100644 index 000000000..5d6ec82d0 --- /dev/null +++ b/reboot/examples/agent-wiki/backend/tests/wiki_crud_test.py @@ -0,0 +1,53 @@ +"""The CRUD scenarios in `wiki_crud.feature`, run with a librarian +model that refuses to be called: these scenarios never add a +transcript, so a librarian call is a bug, and the stand-in turns it +into a clear failure instead of a real Anthropic request.""" + +import pytest +from pydantic_ai.messages import ModelMessage, ModelResponse +from pydantic_ai.models.function import AgentInfo, FunctionModel +from reboot.aio.applications import Application +from reboot.bdd import scenarios +from servicers import wiki as wiki_module +from servicers.wiki import ( + PageServicer, + TranscriptServicer, + UserServicer, + WikiServicer, +) +from typing import Iterator + + +@pytest.fixture +def application() -> Application: + return Application( + servicers=[ + UserServicer, + WikiServicer, + PageServicer, + TranscriptServicer, + ], + ) + + +@pytest.fixture(autouse=True) +def librarian_model() -> Iterator[None]: + """Swaps the librarian's model, for the scenario's duration, for + one that refuses to be called.""" + + def refuse( + messages: list[ModelMessage], + info: AgentInfo, + ) -> ModelResponse: + raise AssertionError( + "Librarian invoked in a scenario that should not trigger " + "ingestion." + ) + + original = wiki_module.librarian.wrapped.model + wiki_module.librarian.wrapped.model = FunctionModel(refuse) + yield + wiki_module.librarian.wrapped.model = original + + +scenarios('wiki_crud.feature') diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature b/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature new file mode 100644 index 000000000..d6afef403 --- /dev/null +++ b/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature @@ -0,0 +1,16 @@ +Feature: Ingesting transcripts through the librarian + + Background: + Given the application is up + And the authenticated user is "alice" + + Scenario: Adding a transcript wakes the librarian + Given the `User` for "alice" gets a `create_wiki` with `name="notes"` and `description="knowledge base"` + And the resulting `wiki_id` is saved as `wiki_id` + When the `Wiki` for "${wiki_id}" gets a `add_transcript` with `messages=[{role: "user", content: "Tell me about X."}, {role: "assistant", content: "X is a thing that does Y."}]` + Then `get` on the `Wiki` for "${wiki_id}" eventually has `content` containing "[Test Page](Page:" within 30 seconds + # The scripted librarian saves ${page_id} the moment its + # `create_page` tool returns, which is before the wiki's content + # updates, so once the line above passes the save exists. + And `get` on the `Wiki` for "${wiki_id}" has `content` containing ${page_id} + And `get` on the `Page` for "${page_id}" has `title="Test Page"` and `content="Distilled transcript content."` diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_ingest_test.py b/reboot/examples/agent-wiki/backend/tests/wiki_ingest_test.py new file mode 100644 index 000000000..a95975630 --- /dev/null +++ b/reboot/examples/agent-wiki/backend/tests/wiki_ingest_test.py @@ -0,0 +1,134 @@ +"""The scenario in `wiki_ingest.feature`: the end-to-end +`Wiki.ingest` librarian workflow, with the LLM replaced by a +scripted Pydantic AI `FunctionModel`.""" + +import pytest +from pydantic_ai.messages import ( + ModelMessage, + ModelResponse, + TextPart, + ToolCallPart, +) +from pydantic_ai.models.function import AgentInfo, FunctionModel +from reboot.aio.applications import Application +from reboot.bdd import scenarios +from reboot.bdd.fixtures import World +from servicers import wiki as wiki_module +from servicers.wiki import ( + PageServicer, + TranscriptServicer, + UserServicer, + WikiServicer, +) +from typing import Iterator + + +class ScriptedLibrarian: + """A stateful scripted Pydantic AI model that drives the + librarian through a fixed sequence of tool calls: + + get_wiki -> create_page -> update_wiki -> end + + Each call sees the agent's conversation history and + decides what to do next based on which tools have + already returned, so the script is robust to any Reboot- + level retries or extra round-trips. The `page_id` + produced by `create_page` is extracted from its tool + return and woven into the `update_wiki` call.""" + + PAGE_TITLE = "Test Page" + PAGE_CONTENT = "Distilled transcript content." + + def __init__(self, world: World) -> None: + self.world = world + self.page_id: str | None = None + + async def step( + self, + messages: list[ModelMessage], + info: AgentInfo, + ) -> ModelResponse: + # Collect the names of tools whose returns we've + # already observed. The librarian is deterministic + # so this is enough to drive the next step. + returned_tools: set[str] = set() + for message in messages: + for part in getattr(message, "parts", []): + if getattr(part, "part_kind", None) != "tool-return": + continue + returned_tools.add(part.tool_name) + # The `create_page` tool returns the new + # page's state ID as a bare string; remember + # it for the `update_wiki` call. + if part.tool_name == "create_page": + self.page_id = str(part.content) + # Save the ID so the scenario can recall it + # as ${page_id}. + self.world.saved['page_id'] = self.page_id + + if "get_wiki" not in returned_tools: + return ModelResponse( + parts=[ + ToolCallPart(tool_name="get_wiki", args={}), + ] + ) + if "create_page" not in returned_tools: + return ModelResponse( + parts=[ + ToolCallPart( + tool_name="create_page", + args={ + "title": self.PAGE_TITLE, + "content": self.PAGE_CONTENT, + }, + ), + ] + ) + if "update_wiki" not in returned_tools: + assert self.page_id is not None, ( + "create_page must have returned before " + "update_wiki" + ) + return ModelResponse( + parts=[ + ToolCallPart( + tool_name="update_wiki", + args={ + "content": + ( + "# Table of contents\n\n" + f"- [Test Page](Page:{self.page_id})\n" + ), + }, + ), + ] + ) + + return ModelResponse(parts=[TextPart(content="Done.")]) + + +@pytest.fixture +def application() -> Application: + return Application( + servicers=[ + UserServicer, + WikiServicer, + PageServicer, + TranscriptServicer, + ], + ) + + +@pytest.fixture(autouse=True) +def script(world: World) -> Iterator[ScriptedLibrarian]: + """Swaps the librarian's model, for the scenario's duration, for + the scripted one, which saves the created page's ID as + `page_id`.""" + scripted = ScriptedLibrarian(world) + original = wiki_module.librarian.wrapped.model + wiki_module.librarian.wrapped.model = FunctionModel(scripted.step) + yield scripted + wiki_module.librarian.wrapped.model = original + + +scenarios('wiki_ingest.feature') diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_test.py b/reboot/examples/agent-wiki/backend/tests/wiki_test.py deleted file mode 100644 index 655f6c396..000000000 --- a/reboot/examples/agent-wiki/backend/tests/wiki_test.py +++ /dev/null @@ -1,393 +0,0 @@ -"""Tests for the agent-wiki backend. - -Covers: -* CRUD on each servicer (`User`, `Wiki`, `Page`, - `Transcript`) via direct Reboot calls. -* The end-to-end `Wiki.ingest` librarian workflow, with the - real Anthropic model swapped for a scripted Pydantic AI - `FunctionModel` so no external API call is made. -""" -import asyncio -import unittest -from agent_wiki.v1.wiki import TranscriptMessage -from agent_wiki.v1.wiki_rbt import Page, Transcript, User, Wiki -from pydantic_ai.messages import ( - ModelMessage, - ModelResponse, - TextPart, - ToolCallPart, -) -from pydantic_ai.models.function import AgentInfo, FunctionModel -from reboot.aio.applications import Application -from reboot.aio.tests import Reboot -from servicers import wiki as wiki_module -from servicers.wiki import ( - PageServicer, - TranscriptServicer, - UserServicer, - WikiServicer, -) - -APPLICATION_SERVICERS = [ - UserServicer, - WikiServicer, - PageServicer, - TranscriptServicer, -] - - -def _null_librarian_model() -> FunctionModel: - """Return a `FunctionModel` that refuses to be called. - Used by tests that should never trigger the librarian; - if they accidentally do, we get a clear failure instead - of a real Anthropic request.""" - - def _refuse( - messages: list[ModelMessage], - info: AgentInfo, - ) -> ModelResponse: - raise AssertionError( - "Librarian invoked in a test that should not " - "trigger ingestion." - ) - - return FunctionModel(_refuse) - - -def _simple_librarian_model() -> FunctionModel: - """Return a `FunctionModel` that always returns the same - response, used by tests that want to trigger the - librarian but don't care about its behavior.""" - - def _respond( - messages: list[ModelMessage], - info: AgentInfo, - ) -> ModelResponse: - return ModelResponse(parts=[TextPart(content="Librarian response")]) - - return FunctionModel(_respond) - - -class _WikiTestBase(unittest.IsolatedAsyncioTestCase): - """Base class that wires up Reboot, creates an `alice` user - context, and swaps the librarian model for the duration of - each test. Subclasses override `_make_librarian_model` to - choose which stand-in model to install.""" - - def _make_librarian_model(self) -> FunctionModel: - raise NotImplementedError - - async def asyncSetUp(self) -> None: - self._original_model = wiki_module.librarian.wrapped.model - # Overwrite the librarian's model within the test, so any calls - # to LLM become deterministic. - wiki_module.librarian.wrapped.model = self._make_librarian_model() - - self.rbt = Reboot() - await self.rbt.start() - await self.rbt.up( - Application( - servicers=APPLICATION_SERVICERS, - ), - ) - self.user_id = "alice" - self.context = await self.rbt.create_external_context_as( - name=f"test-{self.id()}", - user_id=self.user_id, - ) - # `User` is an auto-constructed state type: in - # production the framework calls `_authenticated` for the - # authenticated user when their token is minted. Tests can - # trigger it explicitly here. - await UserServicer._authenticated( - self.context, - state_id=self.user_id, - ) - - async def asyncTearDown(self) -> None: - await self.rbt.stop() - wiki_module.librarian.wrapped.model = self._original_model - - -class ServicerTest(_WikiTestBase): - """Unit tests for each servicer's CRUD methods. These - tests never add a transcript, so the librarian workflow - never actually runs — but we still replace the agent's - model as a belt-and-braces guard against accidental - Anthropic calls from this suite.""" - - def _make_librarian_model(self) -> FunctionModel: - # The tests should never trigger the librarian. - return _null_librarian_model() - - async def test_user_create_and_list_wikis(self) -> None: - """A user can create a wiki and then see it in their - list, keyed by the user-supplied name.""" - user = User.ref("alice") - create_response = await user.create_wiki( - self.context, - name="my notes", - description="my personal notes", - ) - self.assertTrue(create_response.wiki_id) - - list_response = await user.list_wikis(self.context) - self.assertEqual(len(list_response.wikis), 1) - (summary,) = list_response.wikis - self.assertEqual(summary.wiki_id, create_response.wiki_id) - self.assertEqual(summary.name, "my notes") - self.assertEqual(summary.description, "my personal notes") - - async def test_wiki_get_and_update(self) -> None: - """A freshly created wiki exposes its name and - description, starts with empty markdown, and - `update` replaces the markdown body.""" - user = User.ref("alice") - create_response = await user.create_wiki( - self.context, - name="my notes", - description="my personal notes", - ) - wiki = Wiki.ref(create_response.wiki_id) - - got = await wiki.get(self.context) - self.assertEqual(got.name, "my notes") - self.assertEqual(got.description, "my personal notes") - self.assertEqual(got.content, "") - - await wiki.update(self.context, content="# Hello\n") - got = await wiki.get(self.context) - self.assertEqual(got.content, "# Hello\n") - - async def test_page_crud(self) -> None: - """`Page.create` / `get` / `update` round-trip the - title and markdown body.""" - page, _ = await Page.create( - self.context, - title="My Page", - content="Initial body.", - owner_id=self.user_id, - ) - got = await page.get(self.context) - self.assertEqual(got.title, "My Page") - self.assertEqual(got.content, "Initial body.") - - await page.update( - self.context, - title="Renamed Page", - content="New body.", - ) - got = await page.get(self.context) - self.assertEqual(got.title, "Renamed Page") - self.assertEqual(got.content, "New body.") - - async def test_transcript_crud(self) -> None: - """`Transcript.create` / `get` / `update` round-trip - a list of `{role, content}` messages.""" - messages = [ - TranscriptMessage(role="user", content="Hello"), - TranscriptMessage(role="assistant", content="Hi!"), - ] - transcript, _ = await Transcript.create( - self.context, - messages=messages, - owner_id=self.user_id, - ) - got = await transcript.get(self.context) - self.assertEqual(len(got.messages), 2) - self.assertEqual(got.messages[0].role, "user") - self.assertEqual(got.messages[0].content, "Hello") - self.assertEqual(got.messages[1].role, "assistant") - self.assertEqual(got.messages[1].content, "Hi!") - - await transcript.update( - self.context, - messages=[ - TranscriptMessage(role="user", content="Goodbye"), - ], - ) - got = await transcript.get(self.context) - self.assertEqual(len(got.messages), 1) - self.assertEqual(got.messages[0].content, "Goodbye") - - -class ServicerWithSimpleLibrarianTest(_WikiTestBase): - - def _make_librarian_model(self) -> FunctionModel: - # Depending on the timing, that test might trigger the librarian - # when the transcription is added an consumed by `until`. - return _simple_librarian_model() - - async def test_add_transcript_creates_transcript( - self, - ) -> None: - """`Wiki.add_transcript` creates a new `Transcript` - whose state reflects the given messages and returns - its ID.""" - user = User.ref("alice") - create_response = await user.create_wiki( - self.context, - name="notes", - description="", - ) - wiki = Wiki.ref(create_response.wiki_id) - - add_response = await wiki.add_transcript( - self.context, - messages=[ - TranscriptMessage(role="user", content="Hi."), - TranscriptMessage(role="assistant", content="Hello!"), - ], - ) - self.assertTrue(add_response.transcript_id) - - transcript = await Transcript.ref(add_response.transcript_id - ).get(self.context) - self.assertEqual(len(transcript.messages), 2) - self.assertEqual(transcript.messages[0].content, "Hi.") - self.assertEqual(transcript.messages[1].content, "Hello!") - - -class ScriptedLibrarian: - """A stateful scripted Pydantic AI model that drives the - librarian through a fixed sequence of tool calls: - - get_wiki -> create_page -> update_wiki -> end - - Each call sees the agent's conversation history and - decides what to do next based on which tools have - already returned, so the script is robust to any Reboot- - level retries or extra round-trips. The `page_id` - produced by `create_page` is extracted from its tool - return and woven into the `update_wiki` call.""" - - PAGE_TITLE = "Test Page" - PAGE_CONTENT = "Distilled transcript content." - - def __init__(self) -> None: - self.page_id: str | None = None - self.done = asyncio.Event() - - async def step( - self, - messages: list[ModelMessage], - info: AgentInfo, - ) -> ModelResponse: - # Collect the names of tools whose returns we've - # already observed. The librarian is deterministic - # so this is enough to drive the next step. - returned_tools: set[str] = set() - for message in messages: - for part in getattr(message, "parts", []): - if getattr(part, "part_kind", None) != "tool-return": - continue - returned_tools.add(part.tool_name) - # The `create_page` tool returns the new - # page's state ID as a bare string; remember - # it for the `update_wiki` call. - if part.tool_name == "create_page": - self.page_id = str(part.content) - - if "get_wiki" not in returned_tools: - return ModelResponse( - parts=[ - ToolCallPart(tool_name="get_wiki", args={}), - ] - ) - if "create_page" not in returned_tools: - return ModelResponse( - parts=[ - ToolCallPart( - tool_name="create_page", - args={ - "title": self.PAGE_TITLE, - "content": self.PAGE_CONTENT, - }, - ), - ] - ) - if "update_wiki" not in returned_tools: - assert self.page_id is not None, ( - "create_page must have returned before " - "update_wiki" - ) - return ModelResponse( - parts=[ - ToolCallPart( - tool_name="update_wiki", - args={ - "content": - ( - "# Table of contents\n\n" - f"- [Test Page](Page:{self.page_id})\n" - ), - }, - ), - ] - ) - - # Signal done the moment we emit the final response, which means - # the librarian has already executed `update_wiki` and the - # wiki's content is updated by the time any test code waiting on - # `done` wakes up. - self.done.set() - return ModelResponse(parts=[TextPart(content="Done.")]) - - -class IngestWorkflowTest(_WikiTestBase): - """End-to-end test of the `Wiki.ingest` librarian - workflow with the LLM replaced by a `FunctionModel`.""" - - script = ScriptedLibrarian() - - def _make_librarian_model(self) -> FunctionModel: - # Scripted model that drives the librarian through a fixed - # sequence of tool calls. - return FunctionModel(self.script.step) - - async def test_ingest_creates_page_and_updates_wiki( - self, - ) -> None: - """Adding a transcript wakes the librarian, which - runs the scripted `get_wiki -> create_page -> - update_wiki` sequence and marks the transcript - ingested. We verify the wiki's markdown was rewritten - and that the referenced page actually exists with - the scripted title and body.""" - user = User.ref("alice") - create_response = await user.create_wiki( - self.context, - name="notes", - description="knowledge base", - ) - wiki = Wiki.ref(create_response.wiki_id) - - await wiki.add_transcript( - self.context, - messages=[ - TranscriptMessage(role="user", content="Tell me about X."), - TranscriptMessage( - role="assistant", - content="X is a thing that does Y.", - ), - ], - ) - - # Block until the scripted librarian signals it is - # done. `done` is set the moment `step()` emits its - # final `TextPart("Done.")`, at which point - # `update_wiki` has already executed and - # `Wiki.content` is already updated. - await self.script.done.wait() - - state = await wiki.get(self.context) - - # The scripted librarian should have created exactly - # one page and referenced it from the wiki's - # markdown. - self.assertIsNotNone(self.script.page_id) - self.assertIn(f"Page:{self.script.page_id}", state.content) - - page = await Page.ref(self.script.page_id).get(self.context) - self.assertEqual(page.title, ScriptedLibrarian.PAGE_TITLE) - self.assertEqual(page.content, ScriptedLibrarian.PAGE_CONTENT) diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature b/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature new file mode 100644 index 000000000..a435d2fb5 --- /dev/null +++ b/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature @@ -0,0 +1,12 @@ +Feature: Adding transcripts to a wiki + + Background: + Given the application is up + And the authenticated user is "alice" + + Scenario: Adding a transcript creates it + Given the `User` for "alice" gets a `create_wiki` with `name="notes"` and `description=""` + And the resulting `wiki_id` is saved as `wiki_id` + When the `Wiki` for "${wiki_id}" gets a `add_transcript` with `messages=[{role: "user", content: "Hi."}, {role: "assistant", content: "Hello!"}]` + And the resulting `transcript_id` is saved as `transcript_id` + Then `get` on the `Transcript` for "${transcript_id}" has `messages` of length 2 and `messages[0].content="Hi."` and `messages[1].content="Hello!"` diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_transcript_test.py b/reboot/examples/agent-wiki/backend/tests/wiki_transcript_test.py new file mode 100644 index 000000000..e0ded51e9 --- /dev/null +++ b/reboot/examples/agent-wiki/backend/tests/wiki_transcript_test.py @@ -0,0 +1,49 @@ +"""The scenarios in `wiki_transcript.feature`, run with a librarian +model that always answers the same thing: adding a transcript may +wake the librarian, and these scenarios don't care what it does.""" + +import pytest +from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart +from pydantic_ai.models.function import AgentInfo, FunctionModel +from reboot.aio.applications import Application +from reboot.bdd import scenarios +from servicers import wiki as wiki_module +from servicers.wiki import ( + PageServicer, + TranscriptServicer, + UserServicer, + WikiServicer, +) +from typing import Iterator + + +@pytest.fixture +def application() -> Application: + return Application( + servicers=[ + UserServicer, + WikiServicer, + PageServicer, + TranscriptServicer, + ], + ) + + +@pytest.fixture(autouse=True) +def librarian_model() -> Iterator[None]: + """Swaps the librarian's model, for the scenario's duration, for + one that always returns the same response.""" + + def respond( + messages: list[ModelMessage], + info: AgentInfo, + ) -> ModelResponse: + return ModelResponse(parts=[TextPart(content="Librarian response")]) + + original = wiki_module.librarian.wrapped.model + wiki_module.librarian.wrapped.model = FunctionModel(respond) + yield + wiki_module.librarian.wrapped.model = original + + +scenarios('wiki_transcript.feature') diff --git a/reboot/examples/agent-wiki/pyproject.toml b/reboot/examples/agent-wiki/pyproject.toml index a438a8b07..1a9139491 100644 --- a/reboot/examples/agent-wiki/pyproject.toml +++ b/reboot/examples/agent-wiki/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "uuid7>=0.1.0", "anyio>=4.0.0", "pydantic-ai-slim[anthropic]>=1.0.0", - "reboot==1.4.1", + "reboot[pytest-bdd]==1.4.1", ] [dependency-groups] diff --git a/reboot/examples/agent-wiki/uv.lock b/reboot/examples/agent-wiki/uv.lock index 1f7dadc8f..564882e5d 100644 --- a/reboot/examples/agent-wiki/uv.lock +++ b/reboot/examples/agent-wiki/uv.lock @@ -28,7 +28,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.0.0" }, { name = "httpx", specifier = ">=0.27,<1.0" }, { name = "pydantic-ai-slim", extras = ["anthropic"], specifier = ">=1.0.0" }, - { name = "reboot", specifier = "==1.4.1" }, + { name = "reboot", extras = ["pytest-bdd"], specifier = "==1.4.1" }, { name = "uuid7", specifier = ">=0.1.0" }, ] @@ -1894,7 +1894,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1905,9 +1905,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] From ba68894185d6316650136cf767c0c32c811b8885 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 3 Sep 2026 23:35:54 +0000 Subject: [PATCH 23/42] Say custom `reboot.bdd` steps are plain Reboot code 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/steps.py | 4 ++++ tests/reboot/bdd/pydantic/bdd_tests.py | 14 +++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 010d8e303..7a81588ff 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -13,6 +13,10 @@ def application() -> Application: return Application(servicers=[AccountServicer]) +A custom step is plain Reboot code: take the `world` fixture, get +a context from `world.context()`, which carries the scenario's +authenticated user, and call the generated clients directly. + A scenario runs a different application by naming it: 'Given the "proxy" application is up' runs the one the `proxy_application` fixture returns (the quoted name, spaces as underscores, plus diff --git a/tests/reboot/bdd/pydantic/bdd_tests.py b/tests/reboot/bdd/pydantic/bdd_tests.py index d4f94cfb0..88f88af43 100644 --- a/tests/reboot/bdd/pydantic/bdd_tests.py +++ b/tests/reboot/bdd/pydantic/bdd_tests.py @@ -28,9 +28,9 @@ from tests.reboot.bdd.pydantic.account_api_rbt import Account -# A custom `async def` step, the way a developer would write one: it -# calls through `World.call()` rather than importing the generated -# code. +# A custom `async def` step, the way a developer would write one: +# plain Reboot code, a context from the world and calls on the +# generated clients. @when(parsers.parse('"{state_id}" makes {count:d} deposits of {amount:d}')) async def _makes_deposits( world: World, @@ -38,13 +38,9 @@ async def _makes_deposits( count: int, amount: int, ) -> None: + context = world.context() for _ in range(count): - await world.call( - state_type='Account', - state_id=state_id, - method='deposit', - assignments={'amount': amount}, - ) + await Account.ref(state_id).deposit(context, amount=amount) def test_unknown_property_raises() -> None: From 91309206479ad7a5a0895d06fde55eb80069075f Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Fri, 4 Sep 2026 00:00:37 +0000 Subject: [PATCH 24/42] Put `reboot.bdd` predicate arguments in backticks 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/bdd/steps.py | 162 +++++++++--------- .../backend/tests/wiki_crud.feature | 6 +- .../backend/tests/wiki_ingest.feature | 4 +- .../backend/tests/wiki_transcript.feature | 2 +- .../bank-pydantic/backend/tests/bank.feature | 4 +- .../chat-room/backend/tests/chat_room.feature | 2 +- .../chick-potle/backend/tests/food.feature | 8 +- tests/reboot/bdd/accounts.feature | 6 +- tests/reboot/bdd/bdd_tests.py | 10 +- tests/reboot/bdd/pydantic/accounts.feature | 6 +- 10 files changed, 104 insertions(+), 106 deletions(-) diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 7a81588ff..96e5dfccf 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -66,8 +66,9 @@ def application() -> Application: '`reader` on ... aborts with ...'. An asserting list can also say the predicates `path` containing - (a substring of a string, an element of a list, or a key of -a map) and `path` of length . A Given or When 'has' instead +`value` (a substring of a string, an element of a list, or a key of +a map) and `path` of length `n`; the backticked argument is a JSON +value the way a property's value is, so it can recall ${name}. A Given or When 'has' instead saves a property under a backticked name, which later steps recall as `${name}`, in a state's ID, a user's ID, a bearer token, or a property value (a quoted "${name}" stays the literal string): @@ -140,27 +141,29 @@ def application() -> Application: _SAVE_CLAUSE = rf'`{_PATH}`\s+saved\s+(?:as|to)\s+(?:`\w+`|"?\$?\w+"?)' _SAVE_PATTERN = re.compile(rf'`(?P{_PATH})` saved as `(?P\w+)`') -# A predicate clause's argument: a scalar JSON value (a quoted -# string may contain separators), or a '${name}' recall (a bare -# '$name' also matches, so its near-miss routes to the fix). -_ARGUMENT = r'(?:"(?:[^"\\]|\\.)*"|\$\{\w+\}|\$?[-+.\w]+)' - # One containing clause: asserts a substring of a string, an element -# of a list, or a key of a map. The groupless form embeds in step -# patterns and also matches 'contains', so that near-miss routes to -# a step whose parser raises the fix; the compiled form is the -# strict shape, for extraction. -_CONTAINING_CLAUSE = rf'`{_PATH}`\s+contain(?:s|ing)\s+{_ARGUMENT}' +# of a list, or a key of a map; the argument is a backticked JSON +# value, the same grammar as a property's value. The groupless form +# embeds in step patterns and also matches 'contains' and a bare +# argument, so those near-misses route to a step whose parser raises +# the fix; the compiled form is the strict shape, for extraction. +_CONTAINING_CLAUSE = ( + rf'`{_PATH}`\s+contain(?:s|ing)\s+' + r'(?:`[^`]*`|"(?:[^"\\]|\\.)*"|\$?[-+.\w{{}}]+)' +) _CONTAINING_PATTERN = re.compile( - rf'`(?P{_PATH})` containing (?P{_ARGUMENT})' + rf'`(?P{_PATH})` containing `(?P\S[^`]*)`' ) -# One length clause: asserts the length of a string, list, or map. -# The groupless form embeds in step patterns and also matches a -# missing 'of' or a non-integer length, for diagnosis; the compiled -# form is the strict shape, for extraction. -_LENGTH_CLAUSE = rf'`{_PATH}`\s+(?:of\s+)?length\s+{_ARGUMENT}' -_LENGTH_PATTERN = re.compile(rf'`(?P{_PATH})` of length (?P\d+)') +# One length clause: asserts the length of a string, list, or map; +# the length is a backticked value too, so it can recall a save. The +# groupless form embeds in step patterns and also matches a missing +# 'of' or a bare length, for diagnosis; the compiled form is the +# strict shape, for extraction. +_LENGTH_CLAUSE = rf'`{_PATH}`\s+(?:of\s+)?length\s+(?:`[^`]*`|\S+)' +_LENGTH_PATTERN = re.compile( + rf'`(?P{_PATH})` of length `(?P\S[^`]*)`' +) # What separates two clauses in step text: a comma, an 'and', or a # comma followed by an 'and'. @@ -289,31 +292,55 @@ def _almost_property_message(clause: str) -> str: def _almost_containing_message(clause: str) -> str: """The 'Almost' error for a containing clause that is a lexical - near-miss of `path` containing .""" + near-miss of `path` containing `value`.""" if re.search(r'\bcontains\b', clause): return f"Almost: say 'containing', not 'contains': {clause}" + if re.search(r'\bcontaining\s+(?!`)\S', clause): + return ( + "Almost: the value goes in backticks, e.g. containing " + f'`"text"`: {clause}' + ) return ( "Expected a containing clause of the form `path` containing " - f'"value", but got: {clause}' + f"`value`, but got: {clause}" ) def _almost_length_message(clause: str) -> str: """The 'Almost' error for a length clause that is a lexical - near-miss of `path` of length .""" + near-miss of `path` of length `n`.""" if re.search(r'`\s+length\b', clause): return f"Almost: say 'of length', not 'length': {clause}" - if not re.search(r'\blength\s+\d+$', clause): + if re.search(r'\blength\s+(?!`)\S', clause): return ( - "Almost: 'of length' takes a whole number, e.g. of " - f"length 2: {clause}" + "Almost: the length goes in backticks, e.g. of length " + f"`2`: {clause}" ) return ( - "Expected a length clause of the form `path` of length 2, " + "Expected a length clause of the form `path` of length `2`, " f"but got: {clause}" ) +def _parsed_value(world: World, label: str, text: str) -> JsonValue: + """The JSON value the text says, a '${name}' recalling a save; a + lexical near-miss raises the fix.""" + if re.fullmatch(r'\$\w+', text): + raise ValueError( + f"Almost: recall a save as ${{{text[1:]}}}, not {text}" + ) + if re.fullmatch(r'\$\{\w+\}', text): + return _saved_value(world, text[2:-1]) + try: + return json5.loads(text) + except ValueError as error: + raise ValueError( + f"The value of {label} must be JSON, e.g. 50, 2.5, " + '"text", true, or {name: "value"}, but got: ' + f"{text}" + ) from error + + def _almost_within_message(within: str) -> str: """The 'Almost' error for a wait bound that is a lexical near-miss of within seconds.""" @@ -380,24 +407,11 @@ def _parse_assignments( property_match = _PROPERTY_PATTERN.fullmatch(clause_match[0]) if property_match is None: raise ValueError(_almost_property_message(clause_match[0])) - if re.fullmatch(r'\$\w+', property_match['value']): - raise ValueError( - "Almost: recall a save as " - f"${{{property_match['value'][1:]}}}, not " - f"{property_match['value']}" - ) - if re.fullmatch(r'\$\{\w+\}', property_match['value']): - value = _saved_value(world, property_match['value'][2:-1]) - else: - try: - value = json5.loads(property_match['value']) - except ValueError as error: - raise ValueError( - f"The value of `{property_match['path']}` must " - "be JSON, e.g. 50, 2.5, \"text\", true, or " - '{name: "value"}, but got: ' - f"{property_match['value']}" - ) from error + value = _parsed_value( + world, + f"`{property_match['path']}`", + property_match['value'], + ) assignments.append( Assignment( path=PropertyPath.create(property_match['path']), @@ -407,25 +421,6 @@ def _parse_assignments( return assignments -def _parsed_argument(world: World, argument: str) -> JsonValue: - """The JSON value a predicate clause's argument says; a - '${name}' becomes the saved value going by that name.""" - if re.fullmatch(r'\$\w+', argument): - raise ValueError( - f"Almost: recall a save as ${{{argument[1:]}}}, not " - f"{argument}" - ) - if re.fullmatch(r'\$\{\w+\}', argument): - return _saved_value(world, argument[2:-1]) - try: - return json5.loads(argument) - except ValueError as error: - raise ValueError( - f"The argument {argument} must be JSON, e.g. 50, 2.5, " - '"text", or true' - ) from error - - def _parse_assertions( world: World, clauses: Optional[str], @@ -445,18 +440,30 @@ def _parse_assertions( assertions.append( Containing( path=PropertyPath.create(containing_match['path']), - value=_parsed_argument( - world, containing_match['argument'] + value=_parsed_value( + world, + f"`{containing_match['path']}` containing", + containing_match['argument'], ), ) ) continue length_match = _LENGTH_PATTERN.fullmatch(clause) if length_match is not None: + length = _parsed_value( + world, + f"`{length_match['path']}` of length", + length_match['length'], + ) + if isinstance(length, bool) or not isinstance(length, int): + raise ValueError( + f"`{length_match['path']}` of length takes a " + f"whole number, but got: {length!r}" + ) assertions.append( OfLength( path=PropertyPath.create(length_match['path']), - length=int(length_match['length']), + length=length, ) ) continue @@ -467,24 +474,11 @@ def _parse_assertions( property_match = _PROPERTY_PATTERN.fullmatch(clause) if property_match is None: raise ValueError(_almost_property_message(clause)) - if re.fullmatch(r'\$\w+', property_match['value']): - raise ValueError( - "Almost: recall a save as " - f"${{{property_match['value'][1:]}}}, not " - f"{property_match['value']}" - ) - if re.fullmatch(r'\$\{\w+\}', property_match['value']): - value = _saved_value(world, property_match['value'][2:-1]) - else: - try: - value = json5.loads(property_match['value']) - except ValueError as error: - raise ValueError( - f"The value of `{property_match['path']}` must " - "be JSON, e.g. 50, 2.5, \"text\", true, or " - '{name: "value"}, but got: ' - f"{property_match['value']}" - ) from error + value = _parsed_value( + world, + f"`{property_match['path']}`", + property_match['value'], + ) assertions.append( Equals( path=PropertyPath.create(property_match['path']), diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature b/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature index f4d8ae11a..e334e2600 100644 --- a/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature +++ b/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature @@ -7,7 +7,7 @@ Feature: Wiki, page, and transcript CRUD Scenario: A created wiki appears in the user's list When the `User` for "alice" gets a `create_wiki` with `name="my notes"` and `description="my personal notes"` And the resulting `wiki_id` is saved as `wiki_id` - Then `list_wikis` on the `User` for "alice" has `wikis` of length 1 and `wikis[0].wiki_id=${wiki_id}` and `wikis[0].name="my notes"` and `wikis[0].description="my personal notes"` + Then `list_wikis` on the `User` for "alice" has `wikis` of length `1` and `wikis[0].wiki_id=${wiki_id}` and `wikis[0].name="my notes"` and `wikis[0].description="my personal notes"` Scenario: A fresh wiki updates its markdown body Given the `User` for "alice" gets a `create_wiki` with `name="my notes"` and `description="my personal notes"` @@ -24,6 +24,6 @@ Feature: Wiki, page, and transcript CRUD Scenario: Transcripts round-trip their messages Given a `Transcript` for "my-transcript" gets created via `create` with `messages=[{role: "user", content: "Hello"}, {role: "assistant", content: "Hi!"}]` and `owner_id="alice"` - Then `get` on the `Transcript` for "my-transcript" has `messages` of length 2 and `messages[0].role="user"` and `messages[0].content="Hello"` and `messages[1].role="assistant"` and `messages[1].content="Hi!"` + Then `get` on the `Transcript` for "my-transcript" has `messages` of length `2` and `messages[0].role="user"` and `messages[0].content="Hello"` and `messages[1].role="assistant"` and `messages[1].content="Hi!"` When the `Transcript` for "my-transcript" gets a `update` with `messages=[{role: "user", content: "Goodbye"}]` - Then `get` on the `Transcript` for "my-transcript" has `messages` of length 1 and `messages[0].content="Goodbye"` + Then `get` on the `Transcript` for "my-transcript" has `messages` of length `1` and `messages[0].content="Goodbye"` diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature b/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature index d6afef403..d3be57e77 100644 --- a/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature +++ b/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature @@ -8,9 +8,9 @@ Feature: Ingesting transcripts through the librarian Given the `User` for "alice" gets a `create_wiki` with `name="notes"` and `description="knowledge base"` And the resulting `wiki_id` is saved as `wiki_id` When the `Wiki` for "${wiki_id}" gets a `add_transcript` with `messages=[{role: "user", content: "Tell me about X."}, {role: "assistant", content: "X is a thing that does Y."}]` - Then `get` on the `Wiki` for "${wiki_id}" eventually has `content` containing "[Test Page](Page:" within 30 seconds + Then `get` on the `Wiki` for "${wiki_id}" eventually has `content` containing `"[Test Page](Page:"` within 30 seconds # The scripted librarian saves ${page_id} the moment its # `create_page` tool returns, which is before the wiki's content # updates, so once the line above passes the save exists. - And `get` on the `Wiki` for "${wiki_id}" has `content` containing ${page_id} + And `get` on the `Wiki` for "${wiki_id}" has `content` containing `${page_id}` And `get` on the `Page` for "${page_id}" has `title="Test Page"` and `content="Distilled transcript content."` diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature b/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature index a435d2fb5..97dafcd2d 100644 --- a/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature +++ b/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature @@ -9,4 +9,4 @@ Feature: Adding transcripts to a wiki And the resulting `wiki_id` is saved as `wiki_id` When the `Wiki` for "${wiki_id}" gets a `add_transcript` with `messages=[{role: "user", content: "Hi."}, {role: "assistant", content: "Hello!"}]` And the resulting `transcript_id` is saved as `transcript_id` - Then `get` on the `Transcript` for "${transcript_id}" has `messages` of length 2 and `messages[0].content="Hi."` and `messages[1].content="Hello!"` + Then `get` on the `Transcript` for "${transcript_id}" has `messages` of length `2` and `messages[0].content="Hi."` and `messages[1].content="Hello!"` diff --git a/reboot/examples/bank-pydantic/backend/tests/bank.feature b/reboot/examples/bank-pydantic/backend/tests/bank.feature index a5ac2d88c..ee4c317b7 100644 --- a/reboot/examples/bank-pydantic/backend/tests/bank.feature +++ b/reboot/examples/bank-pydantic/backend/tests/bank.feature @@ -15,8 +15,8 @@ Feature: Bank And the `Bank` for "test-bank" gets a `transfer` with `from_account_id=${first_account_id}` and `to_account_id=${second_account_id}` and `amount=250.0` Then `balance` on the `Account` for "${first_account_id}" has `amount=750.0` And `balance` on the `Account` for "${second_account_id}" has `amount=250.0` - And `all_customer_ids` on the `Bank` for "test-bank" has `customer_ids` of length 2 and `customer_ids` containing "test@reboot.dev" and `customer_ids` containing "test2@reboot.dev" - And `account_balances` on the `Bank` for "test-bank" has `balances` of length 2 and `balances[0].customer_id="test@reboot.dev"` and `balances[0].accounts` of length 1 and `balances[0].accounts[0].balance=750.0` and `balances[1].customer_id="test2@reboot.dev"` and `balances[1].accounts` of length 1 and `balances[1].accounts[0].balance=250.0` + And `all_customer_ids` on the `Bank` for "test-bank" has `customer_ids` of length `2` and `customer_ids` containing `"test@reboot.dev"` and `customer_ids` containing `"test2@reboot.dev"` + And `account_balances` on the `Bank` for "test-bank" has `balances` of length `2` and `balances[0].customer_id="test@reboot.dev"` and `balances[0].accounts` of length `1` and `balances[0].accounts[0].balance=750.0` and `balances[1].customer_id="test2@reboot.dev"` and `balances[1].accounts` of length `1` and `balances[1].accounts[0].balance=250.0` Scenario: Overdrafts are refused Given an `Account` for "overdraft-account" gets created via `open` diff --git a/reboot/examples/chat-room/backend/tests/chat_room.feature b/reboot/examples/chat-room/backend/tests/chat_room.feature index 0dd6c2da0..6b52225d4 100644 --- a/reboot/examples/chat-room/backend/tests/chat_room.feature +++ b/reboot/examples/chat-room/backend/tests/chat_room.feature @@ -10,4 +10,4 @@ Feature: Chat room When the `ChatRoom` for "testing-chat-room" gets a `send` with `message="Hello, Reboot!"` And the `ChatRoom` for "testing-chat-room" gets a `send` with `message="Hello, Peace of Mind!"` Then `messages` on the `ChatRoom` for "testing-chat-room" has `messages=["Hello, World", "Hello, Reboot!", "Hello, Peace of Mind!"]` - And `messages` on the `ChatRoom` for "testing-chat-room" has `messages` of length 3 and `messages` containing "Hello, Reboot!" + And `messages` on the `ChatRoom` for "testing-chat-room" has `messages` of length `3` and `messages` containing `"Hello, Reboot!"` diff --git a/reboot/examples/chick-potle/backend/tests/food.feature b/reboot/examples/chick-potle/backend/tests/food.feature index 7ed195b9e..29b7fa6ad 100644 --- a/reboot/examples/chick-potle/backend/tests/food.feature +++ b/reboot/examples/chick-potle/backend/tests/food.feature @@ -7,7 +7,7 @@ Feature: Food orders Scenario: Starting an order pre-populates the menu with an empty cart When the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` - Then `get_menu` on the `FoodOrder` for "${order_id}" has `items` of length 10 and `items[0].name="Chicken Burrito"` and `items[0].category="Burritos"` and `items[0].price_cents=1115` + Then `get_menu` on the `FoodOrder` for "${order_id}" has `items` of length `10` and `items[0].name="Chicken Burrito"` and `items[0].category="Burritos"` and `items[0].price_cents=1115` And `get_cart` on the `FoodOrder` for "${order_id}" has `entries=[]` and `total_cents=0` Scenario: Adding the same item twice increments its quantity @@ -16,15 +16,15 @@ Feature: Food orders When the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=0` and `quantity=1` And the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=0` and `quantity=1` And the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=1` and `quantity=1` - Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length 2 and `entries[0].item_index=0` and `entries[0].quantity=2` and `entries[1].item_index=1` and `entries[1].quantity=1` and `total_cents=3470` + Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length `2` and `entries[0].item_index=0` and `entries[0].quantity=2` and `entries[1].item_index=1` and `entries[1].quantity=1` and `total_cents=3470` When the `FoodOrder` for "${order_id}" gets a `remove_from_cart` with `item_index=0` - Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length 1 and `entries[0].item_index=1` and `total_cents=1240` + Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length `1` and `entries[0].item_index=1` and `total_cents=1240` Scenario: A quantity of zero means one Given the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` When the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=0` and `quantity=0` - Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length 1 and `entries[0].quantity=1` + Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length `1` and `entries[0].quantity=1` Scenario: Out-of-range menu indexes are refused Given the `User` for "alice" gets a `start_order` diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 6700760df..5f2bf520e 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -46,8 +46,8 @@ Feature: Accounts Then `get_owner` on the `Account` for "frank" has `owner={name: "Frankie", tags: ["pro"]}` And `get_owner` on the `Account` for "frank" has `owner.name="Frankie"` And `get_owner` on the `Account` for "frank" has `owner.tags[0]="pro"` - And `get_owner` on the `Account` for "frank" has `owner.name` containing "rank" and `owner.tags` of length 1 - And `get_owner` on the `Account` for "frank" has `owner.tags` containing "pro" + And `get_owner` on the `Account` for "frank" has `owner.name` containing `"rank"` and `owner.tags` of length `1` + And `get_owner` on the `Account` for "frank" has `owner.tags` containing `"pro"` When `get_owner` on the `Account` for "frank" has `owner.name` saved as `owner_name` And an `Account` for "${owner_name}" gets created via `open` with `initial_balance=1` Then `balance` on the `Account` for "Frankie" has `balance=1` @@ -64,7 +64,7 @@ Feature: Accounts When the `Account` for "heidi" gets a `put_owner` with `key="main"` and `owner={name: "Heidi", tags: ["a"]}` Then `get_owners` on the `Account` for "heidi" has `owners["main"].name="Heidi"` And `get_owners` on the `Account` for "heidi" has `owners={main: {name: "Heidi", tags: ["a"]}}` - And `get_owners` on the `Account` for "heidi" has `owners` containing "main" and `owners` of length 1 + And `get_owners` on the `Account` for "heidi" has `owners` containing `"main"` and `owners` of length `1` Scenario: Steps can share one context Given a shared context diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index 90cc135b7..f8d4ddd62 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -132,7 +132,7 @@ def test_clause_grammar_routing() -> None: assert re.fullmatch(_MIXED_CLAUSES, mixed) assert not re.fullmatch(_MIXED_CLAUSES, properties) assert not re.fullmatch(_MIXED_CLAUSES, saves) - predicates = '`name` containing "a and b" and `tags` of length 2' + predicates = '`name` containing `"a and b"` and `tags` of length `2`' assert re.fullmatch(_ASSERT_CLAUSES, predicates) assert re.fullmatch(_ASSERT_CLAUSES, properties) assert not re.fullmatch(_ASSERT_CLAUSES, saves) @@ -204,7 +204,7 @@ def test_parse_assertions() -> None: world = World() assert _parse_assertions( world, - '`name` containing "a and b", `tags` of length 2, and ' + '`name` containing `"a and b"`, `tags` of length `2`, and ' '`balance=50`', ) == [ Containing(path=PropertyPath.create('name'), value='a and b'), @@ -216,7 +216,11 @@ def test_parse_assertions() -> None: with pytest.raises(ValueError, match="'of length', not 'length'"): _parse_assertions(world, '`tags` length 2') with pytest.raises(ValueError, match="takes a whole number"): - _parse_assertions(world, '`tags` of length "2"') + _parse_assertions(world, '`tags` of length `"2"`') + with pytest.raises(ValueError, match="the length goes in backticks"): + _parse_assertions(world, '`tags` of length 2') + with pytest.raises(ValueError, match="the value goes in backticks"): + _parse_assertions(world, '`name` containing "a"') def test_assert_predicates() -> None: diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index 611321194..1a7bd3001 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -29,8 +29,8 @@ Feature: Accounts with a pydantic API When the `Account` for "frank" gets a `set_owner` with `owner.name="Frankie"` and `owner.tags=["pro"]` Then `get_owner` on the `Account` for "frank" has `owner={name: "Frankie", tags: ["pro"]}` And `get_owner` on the `Account` for "frank" has `owner.tags[0]="pro"` - And `get_owner` on the `Account` for "frank" has `owner.name` containing "rank" and `owner.tags` of length 1 - And `get_owner` on the `Account` for "frank" has `owner.tags` containing "pro" + And `get_owner` on the `Account` for "frank" has `owner.name` containing `"rank"` and `owner.tags` of length `1` + And `get_owner` on the `Account` for "frank" has `owner.tags` containing `"pro"` When `get_owner` on the `Account` for "frank" has `owner` saved as `owner` And an `Account` for "franklin" gets created via `open` And the `Account` for "franklin" gets a `set_owner` with `owner=${owner}` @@ -41,7 +41,7 @@ Feature: Accounts with a pydantic API When the `Account` for "heidi" gets a `put_owner` with `key="main"` and `owner={name: "Heidi", tags: ["a"]}` Then `get_owners` on the `Account` for "heidi" has `owners["main"].name="Heidi"` And `get_owners` on the `Account` for "heidi" has `owners={main: {name: "Heidi", tags: ["a"]}}` - And `get_owners` on the `Account` for "heidi" has `owners` containing "main" and `owners` of length 1 + And `get_owners` on the `Account` for "heidi" has `owners` containing `"main"` and `owners` of length `1` Scenario: Steps call as the authenticated user Given the authenticated user is "alice" From 6046ed0a564c2f68f3e35fefcbeb6253dd22dd03 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Fri, 4 Sep 2026 00:09:26 +0000 Subject: [PATCH 25/42] Write the hello-tasks example's tests in Gherkin 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- .../hello-tasks/backend/tests/hello.feature | 15 +++++ .../backend/tests/hello_servicer_test.py | 61 ++++++------------- 2 files changed, 35 insertions(+), 41 deletions(-) create mode 100644 reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature diff --git a/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature b/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature new file mode 100644 index 000000000..5a0433e08 --- /dev/null +++ b/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature @@ -0,0 +1,15 @@ +Feature: Hello with tasks + + Background: + Given the application is up + And the user is unauthenticated + + Scenario: Sent messages get a warning and then erase + When the `Hello` for "testing-hello" gets a `send` with `message="Hello, World!"` + And the resulting `task_id` is saved as `warning_task_id` + # A completed task's response is the result, so the erase task's + # ID saves from it the way any response property does. + When the `warning` task with id "${warning_task_id}" of the `Hello` completes within 30 seconds + And the resulting `task_id` is saved as `erase_task_id` + And the `erase` task with id "${erase_task_id}" of the `Hello` completes within 30 seconds + Then `messages` on the `Hello` for "testing-hello" has `messages` of length `1` and `messages[0]="Number of messages erased so far: 1"` diff --git a/reboot/examples/monorepo/hello-tasks/backend/tests/hello_servicer_test.py b/reboot/examples/monorepo/hello-tasks/backend/tests/hello_servicer_test.py index 4d9dd7749..7a2822b13 100644 --- a/reboot/examples/monorepo/hello-tasks/backend/tests/hello_servicer_test.py +++ b/reboot/examples/monorepo/hello-tasks/backend/tests/hello_servicer_test.py @@ -1,50 +1,29 @@ +"""Hello's tests: the Gherkin scenarios in `hello.feature`.""" + import hello_servicer -import unittest +import pytest from hello_servicer import HelloServicer -from hello_tasks.v1.hello_rbt import Hello from reboot.aio.applications import Application -from reboot.aio.tests import Reboot - - -class TestHello(unittest.IsolatedAsyncioTestCase): - - async def asyncSetUp(self) -> None: - self.rbt = Reboot() - await self.rbt.start() - - async def asyncTearDown(self) -> None: - await self.rbt.stop() +from reboot.bdd import scenarios +from typing import Iterator - async def test_hello_tasks(self) -> None: - # To make our test run quickly, remove delays before erasing the - # message. - hello_servicer.SECS_UNTIL_WARNING = 0 - hello_servicer.ADDITIONAL_SECS_UNTIL_ERASE = 0 - await self.rbt.up( - Application(servicers=[HelloServicer]), - ) - context = self.rbt.create_external_context(name=f"test-{self.id()}") +# To make scenarios run quickly, remove the delays before warning +# about and before erasing a message. +@pytest.fixture(autouse=True) +def no_delays() -> Iterator[None]: + secs_until_warning = hello_servicer.SECS_UNTIL_WARNING + additional_secs_until_erase = hello_servicer.ADDITIONAL_SECS_UNTIL_ERASE + hello_servicer.SECS_UNTIL_WARNING = 0 + hello_servicer.ADDITIONAL_SECS_UNTIL_ERASE = 0 + yield + hello_servicer.SECS_UNTIL_WARNING = secs_until_warning + hello_servicer.ADDITIONAL_SECS_UNTIL_ERASE = additional_secs_until_erase - hello = Hello.ref("testing-hello") - # Send a message. - send_response = await hello.send(context, message="Hello, World!") +@pytest.fixture +def application() -> Application: + return Application(servicers=[HelloServicer]) - # Wait for the message to be erased. - warning_response = await Hello.WarningTask.retrieve( - context, - task_id=send_response.task_id, - ) - await Hello.EraseTask.retrieve( - context, - task_id=warning_response.task_id, - ) - # Check that the current list of messages reflects the erasure. - messages_response = await hello.messages(context) - self.assertEqual(len(messages_response.messages), 1) - self.assertEqual( - messages_response.messages[0], - "Number of messages erased so far: 1", - ) +scenarios('hello.feature') From 70a780cca06a37222f8ac3e0e36913adb81262e4 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Fri, 4 Sep 2026 00:41:28 +0000 Subject: [PATCH 26/42] Write the swag-store example's tests in Gherkin 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- .../examples/reboot-swag-store/.tests/test.sh | 2 +- .../backend/tests/store.feature | 86 ++++ .../backend/tests/store_servicer_test.py | 389 ++++-------------- .../examples/reboot-swag-store/pyproject.toml | 2 +- reboot/examples/reboot-swag-store/uv.lock | 8 +- 5 files changed, 167 insertions(+), 320 deletions(-) create mode 100644 reboot/examples/reboot-swag-store/backend/tests/store.feature diff --git a/reboot/examples/reboot-swag-store/.tests/test.sh b/reboot/examples/reboot-swag-store/.tests/test.sh index 35fa002c9..585a75c0b 100755 --- a/reboot/examples/reboot-swag-store/.tests/test.sh +++ b/reboot/examples/reboot-swag-store/.tests/test.sh @@ -23,7 +23,7 @@ check_lines_in_file() { if [ -n "$REBOOT_WHL_FILE" ]; then # Install the `reboot` package from the specified path # explicitly, overwriting the version from `pyproject.toml`. - uv add --no-sync "${SANDBOX_ROOT}$REBOOT_WHL_FILE" + uv add --no-sync "reboot[pytest-bdd] @ ${SANDBOX_ROOT}$REBOOT_WHL_FILE" fi # Force a fresh virtualenv. A pre-existing `.venv/` (e.g., carried diff --git a/reboot/examples/reboot-swag-store/backend/tests/store.feature b/reboot/examples/reboot-swag-store/backend/tests/store.feature new file mode 100644 index 000000000..164114443 --- /dev/null +++ b/reboot/examples/reboot-swag-store/backend/tests/store.feature @@ -0,0 +1,86 @@ +Feature: Swag store + + Background: + Given the application is up + And the authenticated user is "test-user" + + Scenario: The catalog lists unfiltered, in order + Then `list_products` on the `User` for "test-user" has `products` of length `3` and `products[0].id="hat-1"` and `products[1].id="hoodie-1"` and `products[2].id="tee-1"` + + Scenario: Another user cannot read the cart + Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + When the authenticated user is "other-user" + Then `get_cart` on the `Cart` for "cart-1" aborts with `PermissionDenied` + When the authenticated user is "test-user" + Then `get_cart` on the `Cart` for "cart-1" has `items` of length `1` + + Scenario: Added items appear in the cart + Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + When the `Cart` for "cart-1" gets a `add_item` with `quantity=2` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + Then `get_cart` on the `Cart` for "cart-1" has `items` of length `1` and `items[0].product_id="hoodie-1"` and `items[0].name="Reboot Hoodie"` and `items[0].size="L"` and `items[0].quantity=2` + + Scenario: Adding the same variant increments its quantity + Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + When the `Cart` for "cart-1" gets a `add_item` with `quantity=2` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + Then `get_cart` on the `Cart` for "cart-1" has `items` of length `1` and `items[0].quantity=3` + + Scenario: Adding a different variant adds a line + Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + When the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-s"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="S"` + Then `get_cart` on the `Cart` for "cart-1" has `items` of length `2` and `items[0].size="L"` and `items[1].size="S"` + + Scenario: Removed items leave the cart + Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + When the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + And the `Cart` for "cart-1" gets a `remove_item` with `product_id="hoodie-1"` + Then `get_cart` on the `Cart` for "cart-1" has `items=[]` + + Scenario: Checking out an empty cart is refused + Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + When the `Cart` for "cart-1" attempts a `checkout` with `coupon_code="000000"` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + Then the attempt aborts with `CartEmpty` + + Scenario: An invalid coupon refuses checkout and keeps the cart + Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + When the `Cart` for "cart-1" attempts a `checkout` with `coupon_code="definitely-not-a-real-code"` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + Then the attempt aborts with `InvalidCoupon` + And `get_cart` on the `Cart` for "cart-1" has `items` of length `1` + + Scenario: Checkout empties the cart and creates the order + Given the bearer token is "test-admin-key" + And the `CouponBook` for "coupon-book" gets a `generate_codes` + And the resulting `codes[0]` is saved as `coupon_code` + And the authenticated user is "test-user" + And a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + And the `Cart` for "cart-1" gets a `add_item` with `quantity=2` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + When the `Cart` for "cart-1" gets a `checkout` with `coupon_code=${coupon_code}` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + And the resulting `order_id` is saved as `order_id` + Then `get_cart` on the `Cart` for "cart-1" has `items=[]` + And `get_details` on the `Order` for "${order_id}" has `order_id=${order_id}` and `items` of length `1` and `items[0].product_id="hoodie-1"` and `items[0].quantity=2` and `subtotal_cents=8000` and `total_cents=0` + + Scenario: A redeemed coupon cannot be reused + Given the bearer token is "test-admin-key" + And the `CouponBook` for "coupon-book" gets a `generate_codes` + And the resulting `codes[0]` is saved as `coupon_code` + And the authenticated user is "test-user" + And a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + And the `Cart` for "cart-1" gets a `checkout` with `coupon_code=${coupon_code}` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + And a `Cart` for "cart-2" gets created via `create` with `owner_id="test-user"` + And the `Cart` for "cart-2" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + When the `Cart` for "cart-2" attempts a `checkout` with `coupon_code=${coupon_code}` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + Then the attempt aborts with `InvalidCoupon` + + Scenario: Generating coupon codes requires the admin bearer token + When the `CouponBook` for "coupon-book" attempts a `generate_codes` + Then the attempt aborts with `PermissionDenied` + + Scenario: The admin bearer token generates fresh six-digit codes + Given the bearer token is "test-admin-key" + When the `CouponBook` for "coupon-book" gets a `generate_codes` + Then the result has `codes` of length `20` + And every generated code is six digits diff --git a/reboot/examples/reboot-swag-store/backend/tests/store_servicer_test.py b/reboot/examples/reboot-swag-store/backend/tests/store_servicer_test.py index ff9264643..14562801b 100644 --- a/reboot/examples/reboot-swag-store/backend/tests/store_servicer_test.py +++ b/reboot/examples/reboot-swag-store/backend/tests/store_servicer_test.py @@ -1,25 +1,20 @@ -"""Integration tests for the reboot-swag-store servicers. +"""The swag store's tests: the Gherkin scenarios in +`store.feature`. -These spin up an in-process Reboot, set the admin-key env var to -a known test value so the admin authorizer accepts our test -bearer token, and swap the `OrderServicer.fulfill` workflow for -a no-op so tests don't hit the Printful API. +The scenarios run with the admin key in the environment, the +Printful fulfillment workflow stubbed to a no-op, and the product +catalog mocked, so nothing reaches external services. """ import os -import unittest +import pytest from constants import COUPON_BOOK_ID -from reboot.aio.aborted import Aborted from reboot.aio.applications import Application from reboot.aio.contexts import WorkflowContext -from reboot.aio.tests import Reboot -from reboot_swag_store.v1.store import ( - CartEmpty, - InvalidCoupon, - Product, - ShippingAddress, -) -from reboot_swag_store.v1.store_rbt import Cart, CouponBook, Order, User +from reboot.bdd import scenarios, then +from reboot.bdd.fixtures import World +from reboot_swag_store.v1.store import Product +from reboot_swag_store.v1.store_rbt import CouponBook, Order from servicers.store import ( STORE_ADMIN_KEY_ENV, CartServicer, @@ -27,28 +22,31 @@ OrderServicer, UserServicer, ) +from typing import Iterator from unittest.mock import AsyncMock, patch ADMIN_KEY = "test-admin-key" -SHIPPING = ShippingAddress( - name="Jane Doe", - email="jane@example.com", - address1="123 Main St", - address2="", - city="Seattle", - state_code="WA", - zip_code="98101", - country_code="US", -) -HOODIE = dict( - product_id="hoodie-1", - variant_id="hoodie-1-l", - name="Reboot Hoodie", - price_cents=4000, - image_url="", - size="L", -) +CATALOG = [ + Product( + id="hat-1", + name="Bucket Hat", + description="Embroidered bucket hat.", + price_cents=2500, + ), + Product( + id="hoodie-1", + name="Reboot Hoodie", + description="Heavy-blend hoodie.", + price_cents=4000, + ), + Product( + id="tee-1", + name="Reboot Tee", + description="Classic merch tee.", + price_cents=2000, + ), +] class NoFulfillOrderServicer(OrderServicer): @@ -68,284 +66,47 @@ async def _initialize(context) -> None: await CouponBook.create(context, COUPON_BOOK_ID) -class TestStoreServicers(unittest.IsolatedAsyncioTestCase): - - async def asyncSetUp(self) -> None: - # Provide the admin key the authorizer reads via the - # environment. The Printful API is never called in - # tests because `NoFulfillOrderServicer` short-circuits - # the only code path that would need a token. - self._prev_admin_key = os.environ.get(STORE_ADMIN_KEY_ENV) - os.environ[STORE_ADMIN_KEY_ENV] = ADMIN_KEY - self.rbt = Reboot() - await self.rbt.start() - await self.rbt.up( - Application( - servicers=[ - UserServicer, - CartServicer, - CouponBookServicer, - NoFulfillOrderServicer, - ], - initialize=_initialize, - ) - ) - # Authenticated context for a "guest" user. With - # `--oauth` flag for MCPJam, every - # session — including anonymous ones — gets a stable - # OAuth user-id, which our authorizers rely on. Tests - # bypass the MCP session hook that auto-constructs - # the matching `User` state, so we trigger it here. - self.user_id = "test-user" - self.context = await self.rbt.create_external_context_as( - name=f"test-{self.id()}", - user_id=self.user_id, - ) - await UserServicer._authenticated( - self.context, - state_id=self.user_id, - ) - - async def asyncTearDown(self) -> None: - await self.rbt.stop() - if self._prev_admin_key is None: - os.environ.pop(STORE_ADMIN_KEY_ENV, None) - else: - os.environ[STORE_ADMIN_KEY_ENV] = self._prev_admin_key - - async def _admin_context(self): - return self.rbt.create_external_context( - name=f"admin-{self.id()}", - bearer_token=ADMIN_KEY, - ) - - async def _mint_coupon(self) -> str: - """Generate a fresh coupon code with the admin bearer - token and return one of the new codes.""" - admin_ctx = await self._admin_context() - response = await CouponBook.ref(COUPON_BOOK_ID - ).generate_codes(admin_ctx) - self.assertTrue(response.codes) - return response.codes[0] - - # ----- User.list_products ----------------------------------- - - async def test_list_products_returns_full_catalog(self) -> None: - """The backend never filters: filtering moved to the - client model, which calls `list_products` to read the - catalog, picks the IDs that match the user's intent, - then opens `browse_store` with those IDs. This locks - that contract — `list_products` returns whatever - `fetch_products` produced, in order, with no server- - side filtering.""" - catalog = [ - Product( - id="hat-1", - name="Bucket Hat", - description="Embroidered bucket hat.", - price_cents=2500, - ), - Product( - id="hoodie-1", - name="Reboot Hoodie", - description="Heavy-blend hoodie.", - price_cents=4000, - ), - Product( - id="tee-1", - name="Reboot Tee", - description="Classic merch tee.", - price_cents=2000, - ), - ] - with patch( - "servicers.store.fetch_products", - new=AsyncMock(return_value=catalog), - ): - response = await User.ref(self.user_id).list_products(self.context) - self.assertEqual( - [product.id for product in response.products], - ["hat-1", "hoodie-1", "tee-1"], - ) - - # ----- Authorization ---------------------------------------- - - async def test_different_user_cannot_touch_cart(self) -> None: - """A second guest session — with a valid OAuth token - but a different `user_id` — must not be able to read - someone else's cart. All `Cart` methods share the - `_caller_is_owner` rule, so checking one read is - enough to prove the authorizer wires through; we - don't probe the writers from the wrong user because - Reboot's effect-validation can't safely retry a - non-idempotent mutation that aborted.""" - cart, _ = await Cart.create( - self.context, - owner_id=self.user_id, - ) - await cart.add_item(self.context, quantity=1, **HOODIE) - - # A second authenticated guest session. A fresh - # `Cart.ref` is required because each weak reference - # binds to a single context. - other_context = await self.rbt.create_external_context_as( - name=f"other-{self.id()}", - user_id="other-user", - ) - other_cart = Cart.ref(cart.state_id) - - with self.assertRaises(Aborted): - await other_cart.get_cart(other_context) - - # The original owner still has access. - response = await cart.get_cart(self.context) - self.assertEqual(len(response.items), 1) - - # ----- Cart.add_item / get_cart / remove_item --------------- - - async def test_add_item_and_get_cart(self) -> None: - cart, _ = await Cart.create(self.context, owner_id=self.user_id) - await cart.add_item(self.context, quantity=2, **HOODIE) - response = await cart.get_cart(self.context) - self.assertEqual(len(response.items), 1) - item = response.items[0] - self.assertEqual(item.product_id, "hoodie-1") - self.assertEqual(item.name, "Reboot Hoodie") - self.assertEqual(item.size, "L") - self.assertEqual(item.quantity, 2) - - async def test_add_same_variant_increments_quantity( - self, - ) -> None: - cart, _ = await Cart.create(self.context, owner_id=self.user_id) - await cart.add_item(self.context, quantity=2, **HOODIE) - await cart.add_item(self.context, quantity=1, **HOODIE) - response = await cart.get_cart(self.context) - self.assertEqual(len(response.items), 1) - self.assertEqual(response.items[0].quantity, 3) - - async def test_add_different_variant_adds_a_line( - self, - ) -> None: - cart, _ = await Cart.create(self.context, owner_id=self.user_id) - small = {**HOODIE, "variant_id": "hoodie-1-s", "size": "S"} - await cart.add_item(self.context, quantity=1, **HOODIE) - await cart.add_item(self.context, quantity=1, **small) - response = await cart.get_cart(self.context) - self.assertEqual(len(response.items), 2) - sizes = sorted(item.size for item in response.items) - self.assertEqual(sizes, ["L", "S"]) - - async def test_remove_item(self) -> None: - cart, _ = await Cart.create(self.context, owner_id=self.user_id) - await cart.add_item(self.context, quantity=1, **HOODIE) - await cart.remove_item(self.context, product_id="hoodie-1") - response = await cart.get_cart(self.context) - self.assertEqual(response.items, []) - - # ----- Cart.checkout ---------------------------------------- - - async def test_checkout_on_empty_cart_raises_cart_empty( - self, - ) -> None: - cart, _ = await Cart.create(self.context, owner_id=self.user_id) - with self.assertRaises(Cart.CheckoutAborted) as cm: - await cart.checkout( - self.context, - shipping_address=SHIPPING, - coupon_code="000000", - ) - self.assertIsInstance(cm.exception.error, CartEmpty) - - async def test_checkout_with_invalid_coupon_raises( - self, - ) -> None: - cart, _ = await Cart.create(self.context, owner_id=self.user_id) - await cart.add_item(self.context, quantity=1, **HOODIE) - with self.assertRaises(Cart.CheckoutAborted) as cm: - await cart.checkout( - self.context, - shipping_address=SHIPPING, - coupon_code="definitely-not-a-real-code", - ) - self.assertIsInstance(cm.exception.error, InvalidCoupon) - # The cart is still intact after a failed checkout. - response = await cart.get_cart(self.context) - self.assertEqual(len(response.items), 1) - - async def test_checkout_happy_path(self) -> None: - cart, _ = await Cart.create(self.context, owner_id=self.user_id) - await cart.add_item(self.context, quantity=2, **HOODIE) - - code = await self._mint_coupon() - result = await cart.checkout( - self.context, - shipping_address=SHIPPING, - coupon_code=code, - ) - self.assertTrue(result.order_id) - - # Cart emptied. - response = await cart.get_cart(self.context) - self.assertEqual(response.items, []) - - # Order created with the expected line item. - order = Order.ref(result.order_id) - details = await order.get_details(self.context) - self.assertEqual(details.order_id, result.order_id) - self.assertEqual(len(details.items), 1) - self.assertEqual(details.items[0].product_id, "hoodie-1") - self.assertEqual(details.items[0].quantity, 2) - self.assertEqual(details.subtotal_cents, 8000) - # The coupon makes the order free. - self.assertEqual(details.total_cents, 0) - - async def test_checkout_redeems_coupon_so_reuse_fails( - self, - ) -> None: - code = await self._mint_coupon() - - cart, _ = await Cart.create(self.context, owner_id=self.user_id) - await cart.add_item(self.context, quantity=1, **HOODIE) - await cart.checkout( - self.context, - shipping_address=SHIPPING, - coupon_code=code, - ) - - # Same code a second time should no longer be valid. - cart2, _ = await Cart.create(self.context, owner_id=self.user_id) - await cart2.add_item(self.context, quantity=1, **HOODIE) - with self.assertRaises(Cart.CheckoutAborted) as cm: - await cart2.checkout( - self.context, - shipping_address=SHIPPING, - coupon_code=code, - ) - self.assertIsInstance(cm.exception.error, InvalidCoupon) - - # ----- CouponBook admin gating ------------------------------ - - async def test_generate_codes_requires_admin_bearer( - self, - ) -> None: - book = CouponBook.ref(COUPON_BOOK_ID) - # Anonymous caller: no bearer token. - with self.assertRaises(Aborted): - await book.generate_codes(self.context) - - async def test_generate_codes_with_admin_bearer_succeeds( - self, - ) -> None: - admin_ctx = await self._admin_context() - response = await CouponBook.ref(COUPON_BOOK_ID - ).generate_codes(admin_ctx) - self.assertEqual(len(response.codes), 20) - # Fresh codes are all six digits. - for code in response.codes: - self.assertEqual(len(code), 6) - self.assertTrue(code.isdigit()) - - -if __name__ == "__main__": - unittest.main() +# The admin authorizer reads its key from the environment; give it +# a known one for the scenario's duration. +@pytest.fixture(autouse=True) +def admin_key() -> Iterator[None]: + previous = os.environ.get(STORE_ADMIN_KEY_ENV) + os.environ[STORE_ADMIN_KEY_ENV] = ADMIN_KEY + yield + if previous is None: + os.environ.pop(STORE_ADMIN_KEY_ENV, None) + else: + os.environ[STORE_ADMIN_KEY_ENV] = previous + + +# The catalog comes from Printful in production; mock the fetch +# with a fixed one. +@pytest.fixture(autouse=True) +def catalog() -> Iterator[None]: + with patch( + 'servicers.store.fetch_products', + new=AsyncMock(return_value=CATALOG), + ): + yield + + +@pytest.fixture +def application() -> Application: + return Application( + servicers=[ + UserServicer, + CartServicer, + CouponBookServicer, + NoFulfillOrderServicer, + ], + initialize=_initialize, + ) + + +@then('every generated code is six digits') +def _every_generated_code_is_six_digits(world: World) -> None: + for code in world.response.codes: + assert len(code) == 6 and code.isdigit() + + +scenarios('store.feature') diff --git a/reboot/examples/reboot-swag-store/pyproject.toml b/reboot/examples/reboot-swag-store/pyproject.toml index 4786220ef..beb96da0d 100644 --- a/reboot/examples/reboot-swag-store/pyproject.toml +++ b/reboot/examples/reboot-swag-store/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "httpx>=0.27,<1.0", "python-dotenv>=1.0.0", "uuid7>=0.1.0", - "reboot==1.4.1", + "reboot[pytest-bdd]==1.4.1", ] [dependency-groups] diff --git a/reboot/examples/reboot-swag-store/uv.lock b/reboot/examples/reboot-swag-store/uv.lock index 49513bbed..68712771f 100644 --- a/reboot/examples/reboot-swag-store/uv.lock +++ b/reboot/examples/reboot-swag-store/uv.lock @@ -1673,7 +1673,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1684,9 +1684,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] @@ -1873,7 +1873,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.0.0" }, { name = "httpx", specifier = ">=0.27,<1.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "reboot", specifier = "==1.4.1" }, + { name = "reboot", extras = ["pytest-bdd"], specifier = "==1.4.1" }, { name = "uuid7", specifier = ">=0.1.0" }, ] From 6a7935a7c8d5e360bb3aab7327d42fec0bd153d6 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Fri, 4 Sep 2026 13:46:17 +0000 Subject: [PATCH 27/42] Show the application's behaviors on the dashboard 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- mypy.ini | 2 + rbt/dashboard/v1/BUILD.bazel | 6 + rbt/dashboard/v1/dashboard.proto | 36 ++ rbt/v1alpha1/bdd/BUILD.bazel | 47 ++ rbt/v1alpha1/bdd/feature.proto | 136 ++++ rbt/v1alpha1/bdd/grammar.proto | 234 +++++++ rbt/v1alpha1/bdd/package.json | 3 + reboot/bdd/BUILD.bazel | 23 + reboot/bdd/feature.py | 152 +++++ reboot/bdd/grammar.py | 386 ++++++++++++ reboot/bdd/steps.py | 298 +++------ reboot/dashboard/backend/BUILD.bazel | 16 + reboot/dashboard/backend/behaviors_watcher.py | 134 ++++ reboot/dashboard/backend/main.py | 2 + reboot/dashboard/backend/servicers.py | 46 +- reboot/dashboard/web/BUILD.bazel | 3 + reboot/dashboard/web/dashboard.css | 237 +++++++ reboot/dashboard/web/src/behaviors.ts | 513 +++++++++++++++ reboot/dashboard/web/src/main.tsx | 587 +++++++++++++++++- reboot/requirements-pytest-bdd.in | 1 + reboot/requirements.in | 1 + reboot/requirements_lock.txt | 4 +- tests/reboot/bdd/BUILD.bazel | 19 + tests/reboot/bdd/bdd_tests.py | 48 +- tests/reboot/bdd/feature_tests.py | 60 ++ tests/reboot/bdd/grammar_tests.py | 198 ++++++ tests/reboot/dashboard/BUILD.bazel | 11 + .../dashboard/behaviors_watcher_tests.py | 220 +++++++ 28 files changed, 3164 insertions(+), 259 deletions(-) create mode 100644 rbt/v1alpha1/bdd/BUILD.bazel create mode 100644 rbt/v1alpha1/bdd/feature.proto create mode 100644 rbt/v1alpha1/bdd/grammar.proto create mode 100644 rbt/v1alpha1/bdd/package.json create mode 100644 reboot/bdd/feature.py create mode 100644 reboot/bdd/grammar.py create mode 100644 reboot/dashboard/backend/behaviors_watcher.py create mode 100644 reboot/dashboard/web/src/behaviors.ts create mode 100644 tests/reboot/bdd/feature_tests.py create mode 100644 tests/reboot/bdd/grammar_tests.py create mode 100644 tests/reboot/dashboard/behaviors_watcher_tests.py diff --git a/mypy.ini b/mypy.ini index 0abd7e185..278613802 100644 --- a/mypy.ini +++ b/mypy.ini @@ -56,6 +56,8 @@ ignore_missing_imports = True [mypy-envoy.*] ignore_missing_imports = True follow_imports = skip +[mypy-gherkin.*] +ignore_missing_imports = True [mypy-git.*] ignore_missing_imports = True [mypy-google] diff --git a/rbt/dashboard/v1/BUILD.bazel b/rbt/dashboard/v1/BUILD.bazel index 50c808db8..6a6d6e134 100644 --- a/rbt/dashboard/v1/BUILD.bazel +++ b/rbt/dashboard/v1/BUILD.bazel @@ -17,6 +17,8 @@ proto_library( "@com_github_reboot_dev_reboot//rbt/v1alpha1:options_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:api_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:schema_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:feature_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:grammar_proto", "@com_google_protobuf//:struct_proto", "@com_google_protobuf//:timestamp_proto", ], @@ -41,6 +43,8 @@ js_proto_library( "@com_github_reboot_dev_reboot//rbt/v1alpha1:options_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:api_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:schema_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:feature_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:grammar_proto", "@com_google_protobuf//:descriptor_proto", "@com_google_protobuf//:timestamp_proto", ], @@ -70,6 +74,8 @@ js_reboot_react_library( "@com_github_reboot_dev_reboot//rbt/v1alpha1:options_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:api_proto", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:schema_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:feature_proto", + "@com_github_reboot_dev_reboot//rbt/v1alpha1/bdd:grammar_proto", "@com_google_protobuf//:descriptor_proto", "@com_google_protobuf//:timestamp_proto", ], diff --git a/rbt/dashboard/v1/dashboard.proto b/rbt/dashboard/v1/dashboard.proto index 93d56f3f1..4e51e82f3 100644 --- a/rbt/dashboard/v1/dashboard.proto +++ b/rbt/dashboard/v1/dashboard.proto @@ -6,6 +6,7 @@ import "google/protobuf/timestamp.proto"; import "rbt/v1alpha1/options.proto"; import "rbt/v1alpha1/api/api.proto"; import "rbt/v1alpha1/api/schema.proto"; +import "rbt/v1alpha1/bdd/feature.proto"; //////////////////////////////////////////////////////////////////////// @@ -66,6 +67,11 @@ message Dashboard { // code is missing or out of date, which is what suggests running // `rbt generate`. map generated = 8; + + // What each of the developer's `.feature` files declares, keyed + // by the file's path relative to the working directory, as of the + // last write. + map features = 9; } message DashboardGetRequest {} @@ -94,6 +100,8 @@ message DashboardGetResponse { // The worst reason over all the API files, and absent when // nothing says to run `rbt generate`. optional NeedsGenerateReason needs_generate_reason = 8; + + map features = 9; } message DashboardUpdateApiRequest { @@ -388,6 +396,17 @@ message DashboardWatchCodeRequest {} message DashboardWatchCodeResponse {} +message DashboardUpdateBehaviorsRequest { + // Keyed the way `Dashboard.features` is. + map features = 1; +} + +message DashboardUpdateBehaviorsResponse {} + +message DashboardWatchBehaviorsRequest {} + +message DashboardWatchBehaviorsResponse {} + //////////////////////////////////////////////////////////////////////// // One servicer found in the developer's application, and the state @@ -644,6 +663,23 @@ service DashboardMethods { option (rbt.v1alpha1.method).workflow = { }; } + + // Replaces what the developer's `.feature` files declare. Its own + // writer, so that recording behaviors never writes back what the + // other two updates record. + rpc UpdateBehaviors(DashboardUpdateBehaviorsRequest) + returns (DashboardUpdateBehaviorsResponse) { + option (rbt.v1alpha1.method).writer = { + }; + } + + // Watches the developer's `.feature` files for as long as the + // dashboard application runs, parsing each one that changes. + rpc WatchBehaviors(DashboardWatchBehaviorsRequest) + returns (DashboardWatchBehaviorsResponse) { + option (rbt.v1alpha1.method).workflow = { + }; + } } //////////////////////////////////////////////////////////////////////// diff --git a/rbt/v1alpha1/bdd/BUILD.bazel b/rbt/v1alpha1/bdd/BUILD.bazel new file mode 100644 index 000000000..7d5e2ea0e --- /dev/null +++ b/rbt/v1alpha1/bdd/BUILD.bazel @@ -0,0 +1,47 @@ +load("@com_github_grpc_grpc//bazel:python_rules.bzl", "py_proto_library") +load("@com_github_reboot_dev_reboot//reboot:rules.bzl", "js_proto_library") +load("@com_google_protobuf//bazel:proto_library.bzl", "proto_library") + +proto_library( + name = "grammar_proto", + srcs = [":grammar.proto"], + visibility = ["//visibility:public"], +) + +py_proto_library( + name = "grammar_py_proto", + visibility = ["//visibility:public"], + deps = [":grammar_proto"], +) + +js_proto_library( + name = "grammar_js_proto", + package_json = ":package.json", + proto = ":grammar.proto", + proto_deps = [":grammar_proto"], + visibility = ["//visibility:public"], +) + +proto_library( + name = "feature_proto", + srcs = [":feature.proto"], + visibility = ["//visibility:public"], + deps = [":grammar_proto"], +) + +py_proto_library( + name = "feature_py_proto", + visibility = ["//visibility:public"], + deps = [":feature_proto"], +) + +js_proto_library( + name = "feature_js_proto", + package_json = ":package.json", + proto = ":feature.proto", + proto_deps = [ + ":feature_proto", + ":grammar_proto", + ], + visibility = ["//visibility:public"], +) diff --git a/rbt/v1alpha1/bdd/feature.proto b/rbt/v1alpha1/bdd/feature.proto new file mode 100644 index 000000000..567723f29 --- /dev/null +++ b/rbt/v1alpha1/bdd/feature.proto @@ -0,0 +1,136 @@ +syntax = "proto3"; + +package rbt.v1alpha1.bdd; + +import "rbt/v1alpha1/bdd/grammar.proto"; + +//////////////////////////////////////////////////////////////////////// + +// What one of the developer's `.feature` files declares: the +// feature, the scenarios that belong to it directly, and the rules +// it groups the rest of its scenarios under. +message Feature { + // The keyword as written, e.g. "Feature"; Gherkin lets a file + // write its keywords in its own language. + string keyword = 1; + + // Absent for a bare heading naming nothing. + optional string name = 2; + + // The prose under the heading, dedented; absent for none. + optional string description = 3; + + // Tag names as written, `@` included, e.g. "@wip". + repeated string tags = 4; + + // The steps every scenario in the file begins with; absent when + // the file declares none. + optional Background background = 5; + + // The scenarios before the first rule, which belong to the + // feature directly: once a rule starts, every scenario after it + // belongs to a rule. + repeated Scenario scenarios = 6; + + repeated Rule rules = 7; + + // Why the file could not be parsed, when it could not be; a + // half-written file is the normal cause while someone is typing. + // A feature carrying an error carries nothing else. + optional string error = 8; +} + +// One business rule of a feature, illustrated by the scenarios +// grouped under it. +message Rule { + string keyword = 1; + + // Absent for a bare heading naming nothing. + optional string name = 2; + + optional string description = 3; + + repeated string tags = 4; + + // The steps every scenario under this rule begins with, run after + // the feature's own background; absent when the rule declares + // none. + optional Background background = 5; + + repeated Scenario scenarios = 6; +} + +// The steps a feature's or a rule's scenarios share, run before +// each scenario's own. +message Background { + string keyword = 1; + + // Absent for a bare heading naming nothing. + optional string name = 2; + + optional string description = 3; + + repeated Step steps = 4; +} + +// One scenario, which runs as one test. The keyword may be +// "Scenario", its synonym "Example", or "Scenario Outline" for one +// templated over examples tables. +message Scenario { + string keyword = 1; + + // Absent for a bare heading naming nothing. + optional string name = 2; + + optional string description = 3; + + repeated string tags = 4; + + repeated Step steps = 5; + + // The line the scenario is declared on, counting from one. + uint32 line = 6; + + // The examples tables a "Scenario Outline" is templated over; + // empty for a plain scenario. + repeated Examples examples = 7; +} + +// One step of a scenario or a background. +message Step { + // The keyword as written but without its trailing space, e.g. + // "Given" or "And". + string keyword = 1; + + string text = 2; + + // The step's doc string argument, when it has one. + optional string doc_string = 3; + + // The step's data table argument, when it has one. + optional Table table = 4; + + // The text's syntax tree under the built-in steps' grammar; absent + // for a step the grammar does not define, such as one the + // application defines itself. + optional BuiltInSyntax built_in = 5; +} + +// Rows of cells: a step's data table, or an examples table. +message Table { + message Row { + repeated string cells = 1; + } + + repeated Row rows = 1; +} + +// One examples table of a "Scenario Outline", its header row first. +message Examples { + string keyword = 1; + + // Absent for a bare heading naming nothing. + optional string name = 2; + + Table table = 3; +} diff --git a/rbt/v1alpha1/bdd/grammar.proto b/rbt/v1alpha1/bdd/grammar.proto new file mode 100644 index 000000000..cd6768d13 --- /dev/null +++ b/rbt/v1alpha1/bdd/grammar.proto @@ -0,0 +1,234 @@ +syntax = "proto3"; + +package rbt.v1alpha1.bdd; + +//////////////////////////////////////////////////////////////////////// + +// A value as a step writes it: JSON (JSON5, so keys need no quotes), +// parsed against the property it is set on or asserted against when +// the scenario runs, since what `1` means depends on that property's +// type. May hold a variable, `${name}`, whose saved value is spliced +// in first; a value that is only a variable is the saved value +// itself. +message Value { + string json = 1; +} + +// The state a step acts on: 'the `Account` for "alice"'. +message State { + // The state type as the step spells it, e.g. `Account`, which a + // step may qualify with its package. + string type = 1; + + // The id as written, without its quotes; may be a variable, + // `${name}`, whose saved value is the id. + string id = 2; +} + +// A `path=value` clause of a call's `with`: the property set and +// what it is set to. +message Assignment { + string path = 1; + + Value value = 2; +} + +// A `path=value` clause of an asserting list: the property equals +// the value. +message Equals { + string path = 1; + + Value value = 2; +} + +// A `path` containing `argument` clause: a substring of a string, an +// element of a list, or a key of a map. +message Containing { + string path = 1; + + Value argument = 2; +} + +// A `path` of length `length` clause: the length of a string, list, +// or map. +message OfLength { + string path = 1; + + Value length = 2; +} + +// One clause of an asserting list. +message Assertion { + oneof assertion { + Equals equals = 1; + Containing containing = 2; + OfLength of_length = 3; + } +} + +// A `path` saved as `name` clause: the property read and the name it +// is saved under. +message Save { + string path = 1; + + string name = 2; +} + +//////////////////////////////////////////////////////////////////////// + +// 'the application is up', or 'the "name" application is up' for one +// of several. +message ApplicationIsUp { + optional string name = 1; +} + +// 'the authenticated user is "user_id"'. +message AuthenticatedUserIs { + string user_id = 1; +} + +// 'the user is unauthenticated'. +message UserIsUnauthenticated {} + +// 'the bearer token is "bearer_token"'. +message BearerTokenIs { + string bearer_token = 1; +} + +// 'a shared context'. +message SharedContext {} + +// 'a `Account` for "alice" gets created via `open` with ...'. +message GetsCreatedVia { + State state = 1; + + string method = 2; + + repeated Assignment assignments = 3; +} + +// 'the `Account` for "alice" gets a `deposit` with ...', and +// optionally 'spawned with its task id saved as `name`'. +message Gets { + State state = 1; + + string method = 2; + + repeated Assignment assignments = 3; + + // The name the spawned task's id is saved under; absent for a + // call that is not spawned. + optional string task_id_saved_as = 4; +} + +// 'the `Account` for "alice" attempts a `withdraw` with ...'. +message Attempts { + State state = 1; + + string method = 2; + + repeated Assignment assignments = 3; +} + +// 'the `deposit` task with id "${name}" of the `Account` completes +// within 30 seconds'. +message TaskCompletes { + string method = 1; + + // The name the task's id was saved under. + string task_id_saved_as = 2; + + string state_type = 3; + + double seconds = 4; +} + +// 'the attempt aborts with `OverdraftError` with ...'. +message AttemptAbortsWith { + string error_type = 1; + + repeated Assertion assertions = 2; +} + +// '`balance` on the `Account` for "alice" has ...': a reader, whose +// response is asserted on. A writer, transaction, or workflow is +// called with `Gets` and its result asserted with `ResultHas`. +message Has { + string method = 1; + + State state = 2; + + repeated Assertion assertions = 3; +} + +// '`balance` on the `Account` for "alice" eventually has ... within +// 30 seconds': a reader, read reactively until its response +// satisfies the assertions or the bound passes. +message EventuallyHas { + string method = 1; + + State state = 2; + + repeated Assertion assertions = 3; + + double seconds = 4; +} + +// '`get` on the `Account` for "alice" has `owner` saved as `o`': a +// reader, whose response is saved from. +message HasSavedAs { + string method = 1; + + State state = 2; + + repeated Save saves = 3; +} + +// '`balance` on the `Account` for "alice" aborts with +// `OverdraftError` with ...': a reader that aborts. A writer, +// transaction, or workflow that aborts is called with `Attempts` +// and its abort asserted with `AttemptAbortsWith`. +message AbortsWith { + string method = 1; + + State state = 2; + + string error_type = 3; + + repeated Assertion assertions = 4; +} + +// 'the result has ...'. +message ResultHas { + repeated Assertion assertions = 1; +} + +// 'the resulting `account_id` is saved as `alice_account_id`'. +message ResultingIsSavedAs { + Save save = 1; +} + +// The syntax tree of a step's text under the grammar of the built-in +// `reboot.bdd` steps: which built-in step it is, and the parts the +// step takes. Only a text one of the built-in steps matches has one; +// a step an application defines itself does not. +message BuiltInSyntax { + oneof step { + ApplicationIsUp application_is_up = 1; + AuthenticatedUserIs authenticated_user_is = 2; + UserIsUnauthenticated user_is_unauthenticated = 3; + BearerTokenIs bearer_token_is = 4; + SharedContext shared_context = 5; + GetsCreatedVia gets_created_via = 6; + Gets gets = 7; + Attempts attempts = 8; + TaskCompletes task_completes = 9; + AttemptAbortsWith attempt_aborts_with = 10; + Has has = 11; + EventuallyHas eventually_has = 12; + HasSavedAs has_saved_as = 13; + AbortsWith aborts_with = 14; + ResultHas result_has = 15; + ResultingIsSavedAs resulting_is_saved_as = 16; + } +} diff --git a/rbt/v1alpha1/bdd/package.json b/rbt/v1alpha1/bdd/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/rbt/v1alpha1/bdd/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/reboot/bdd/BUILD.bazel b/reboot/bdd/BUILD.bazel index 96be3b114..960c1913d 100644 --- a/reboot/bdd/BUILD.bazel +++ b/reboot/bdd/BUILD.bazel @@ -19,6 +19,28 @@ py_library( visibility = ["//visibility:public"], ) +py_library( + name = "feature_py", + srcs = ["feature.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + ":grammar_py", + requirement("gherkin-official"), + "//rbt/v1alpha1/bdd:feature_py_proto", + ], +) + +py_library( + name = "grammar_py", + srcs = ["grammar.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + "//rbt/v1alpha1/bdd:grammar_py_proto", + ], +) + py_library( name = "registry_py", srcs = ["registry.py"], @@ -56,6 +78,7 @@ py_library( deps = [ ":__init___py", ":fixtures_py", + ":grammar_py", ":registry_py", requirement("json5"), requirement("jsonpath-ng"), diff --git a/reboot/bdd/feature.py b/reboot/bdd/feature.py new file mode 100644 index 000000000..ae76961fe --- /dev/null +++ b/reboot/bdd/feature.py @@ -0,0 +1,152 @@ +"""What a `.feature` file declares, as `rbt/v1alpha1/bdd/feature.proto` +declares it: the feature, its rules, their scenarios, and each step +parsed by the built-in steps' grammar. + +Parsing is `gherkin-official`'s, which arrives with +`reboot[pytest-bdd]`, as does this package. +""" +import gherkin.errors +import gherkin.parser +import gherkin.token_scanner +from rbt.v1alpha1.bdd.feature_pb2 import ( + Background, + Examples, + Feature, + Rule, + Scenario, + Step, + Table, +) +from reboot.bdd import grammar +from textwrap import dedent +from typing import Any, Optional + + +def _description(parsed: dict[str, Any]) -> Optional[str]: + """The prose under a heading, dedented, since the parser keeps + each line's indentation, which on the page would read as + accidental; `None` for a heading with none under it, which the + parser reports as an empty string.""" + description = dedent(parsed['description']).strip() + return description if description != '' else None + + +def _name(parsed: dict[str, Any]) -> Optional[str]: + """What a heading names, and `None` for a bare heading naming + nothing, which the parser reports as an empty string.""" + return parsed['name'] if parsed['name'] != '' else None + + +def _tags(parsed: dict[str, Any]) -> list[str]: + return [tag['name'] for tag in parsed['tags']] + + +def _table(rows: list[dict[str, Any]]) -> Table: + return Table( + rows=[ + Table.Row(cells=[cell['value'] + for cell in row['cells']]) + for row in rows + ] + ) + + +def _step(parsed: dict[str, Any]) -> Step: + return Step( + keyword=parsed['keyword'].strip(), + text=parsed['text'], + doc_string=( + parsed['docString']['content'] if 'docString' in parsed else None + ), + table=( + _table(parsed['dataTable']['rows']) + if 'dataTable' in parsed else None + ), + built_in=grammar.parse(parsed['text']), + ) + + +def _background(parsed: dict[str, Any]) -> Background: + return Background( + keyword=parsed['keyword'], + name=_name(parsed), + description=_description(parsed), + steps=[_step(step) for step in parsed['steps']], + ) + + +def _examples(parsed: dict[str, Any]) -> Examples: + rows = [] + if parsed.get('tableHeader') is not None: + rows.append(parsed['tableHeader']) + rows.extend(parsed.get('tableBody', [])) + return Examples( + keyword=parsed['keyword'], + name=_name(parsed), + table=_table(rows), + ) + + +def _scenario(parsed: dict[str, Any]) -> Scenario: + return Scenario( + keyword=parsed['keyword'], + name=_name(parsed), + description=_description(parsed), + tags=_tags(parsed), + steps=[_step(step) for step in parsed['steps']], + line=parsed['location']['line'], + examples=[_examples(examples) for examples in parsed['examples']], + ) + + +def _rule(parsed: dict[str, Any]) -> Rule: + background: Optional[Background] = None + scenarios: list[Scenario] = [] + for child in parsed['children']: + if 'background' in child: + background = _background(child['background']) + elif 'scenario' in child: + scenarios.append(_scenario(child['scenario'])) + return Rule( + keyword=parsed['keyword'], + name=_name(parsed), + description=_description(parsed), + tags=_tags(parsed), + background=background, + scenarios=scenarios, + ) + + +def parse(source: str) -> Optional[Feature]: + """What one feature file declares: a `Feature` carrying only why + the source could not be parsed when it could not be, and `None` + for a source that declares no feature at all, such as one holding + only comments.""" + try: + document = gherkin.parser.Parser().parse( + gherkin.token_scanner.TokenScanner(source) + ) + except gherkin.errors.CompositeParserException as error: + return Feature(error=str(error)) + parsed = document.get('feature') + if parsed is None: + return None + background: Optional[Background] = None + scenarios: list[Scenario] = [] + rules: list[Rule] = [] + for child in parsed['children']: + if 'background' in child: + background = _background(child['background']) + elif 'scenario' in child: + scenarios.append(_scenario(child['scenario'])) + elif 'rule' in child: + rules.append(_rule(child['rule'])) + return Feature( + keyword=parsed['keyword'], + name=_name(parsed), + description=_description(parsed), + tags=_tags(parsed), + background=background, + scenarios=scenarios, + rules=rules, + ) diff --git a/reboot/bdd/grammar.py b/reboot/bdd/grammar.py new file mode 100644 index 000000000..870d9cb3f --- /dev/null +++ b/reboot/bdd/grammar.py @@ -0,0 +1,386 @@ +"""The grammar of the built-in steps: the regular expressions that +match a step's text, and a parse of a step's text into its syntax +tree, which built-in step it is and the parts the step takes, as +`rbt/v1alpha1/bdd/grammar.proto` declares them. + +Kept apart from the steps themselves so that reading a step needs +nothing of pytest-bdd: the dashboard reads `.feature` files with this +same grammar, which is what lets it say that a span is a state type +rather than guess from its spelling. +""" +import re +from rbt.v1alpha1.bdd.grammar_pb2 import ( + AbortsWith, + ApplicationIsUp, + Assertion, + Assignment, + AttemptAbortsWith, + Attempts, + AuthenticatedUserIs, + BearerTokenIs, + BuiltInSyntax, + Containing, + Equals, + EventuallyHas, + Gets, + GetsCreatedVia, + Has, + HasSavedAs, + OfLength, + ResultHas, + ResultingIsSavedAs, + Save, + SharedContext, + State, + TaskCompletes, + UserIsUnauthenticated, + Value, +) +from typing import Optional + +# A property path in step text: a leading field, then dotted fields, +# bracketed list indices, and bracketed map keys. +PATH = r'\w+(?:\.\w+|\[\d+\]|\["[^"]*"\])*' + +# One 'path=value' property clause: the property's path and value +# in backticks, the value being anything up to the closing backtick. +# The groupless form embeds in step patterns and deliberately also +# matches lexical near-misses (':' for '=', spaces around the '=', +# an empty value) so that those route to a step whose parser +# raises the fix; the compiled form is the strict shape, for +# extraction. +PROPERTY_CLAUSE = rf'`{PATH}\s*[:=]\s*[^`]*`' +PROPERTY_PATTERN = re.compile(rf'`(?P{PATH})=(?P\S[^`]*)`') + +# One saving clause: the (possibly dotted) property path in +# backticks, saved under a backticked name. The groupless form +# embeds in step patterns and deliberately also matches lexical +# near-misses ('saved to', a quoted or '$'-prefixed name) so that +# those route to a step whose parser raises the fix; the compiled +# form is the strict shape, for extraction. +SAVE_CLAUSE = rf'`{PATH}`\s+saved\s+(?:as|to)\s+(?:`\w+`|"?\$?\w+"?)' +SAVE_PATTERN = re.compile(rf'`(?P{PATH})` saved as `(?P\w+)`') + +# One containing clause: asserts a substring of a string, an element +# of a list, or a key of a map; the argument is a backticked JSON +# value, the same grammar as a property's value. The groupless form +# embeds in step patterns and also matches 'contains' and a bare +# argument, so those near-misses route to a step whose parser raises +# the fix; the compiled form is the strict shape, for extraction. +CONTAINING_CLAUSE = ( + rf'`{PATH}`\s+contain(?:s|ing)\s+' + r'(?:`[^`]*`|"(?:[^"\\]|\\.)*"|\$?[-+.\w{{}}]+)' +) +CONTAINING_PATTERN = re.compile( + rf'`(?P{PATH})` containing `(?P\S[^`]*)`' +) + +# One length clause: asserts the length of a string, list, or map; +# the length is a backticked value too, so it can recall a save. The +# groupless form embeds in step patterns and also matches a missing +# 'of' or a bare length, for diagnosis; the compiled form is the +# strict shape, for extraction. +LENGTH_CLAUSE = rf'`{PATH}`\s+(?:of\s+)?length\s+(?:`[^`]*`|\S+)' +LENGTH_PATTERN = re.compile( + rf'`(?P{PATH})` of length `(?P\S[^`]*)`' +) + +# What separates two clauses in step text: a comma, an 'and', or a +# comma followed by an 'and'. +SEPARATOR = r'\s*(?:,\s*and|,|and)\s+' + +# A clause list of only 'path=value' properties: what a call's +# 'with' passes. +PROPERTY_CLAUSES = rf'{PROPERTY_CLAUSE}(?:{SEPARATOR}{PROPERTY_CLAUSE})*' + +# One asserting clause: an equality or a predicate. +ASSERT_CLAUSE = (rf'(?:{PROPERTY_CLAUSE}|{CONTAINING_CLAUSE}|{LENGTH_CLAUSE})') + +# A clause list of asserting clauses: what a Then 'has' and an +# abort's 'with' assert. +ASSERT_CLAUSES = rf'{ASSERT_CLAUSE}(?:{SEPARATOR}{ASSERT_CLAUSE})*' + +# A clause list of only saving clauses: what a Given or When 'has' +# saves. +SAVE_CLAUSES = rf'{SAVE_CLAUSE}(?:{SEPARATOR}{SAVE_CLAUSE})*' + +# A clause list mixing both kinds, which no step accepts; it exists +# so the mistake gets a pointed error instead of an unmatched step. +# A property value can never contain a backtick, so the lookaheads +# can only hit an actual clause of each kind. +CLAUSE = rf'(?:{ASSERT_CLAUSE}|{SAVE_CLAUSE})' +MIXED_CLAUSES = ( + rf'(?=.*`\s+saved\s)' + rf'(?=.*(?:`{PATH}\s*[:=]|`{PATH}`\s+contain|' + rf'`{PATH}`\s+(?:of\s+)?length))' + rf'{CLAUSE}(?:{SEPARATOR}{CLAUSE})*' +) + +# The 'the `Account` for "alice"' phrase naming the state a step acts +# on. +STATE = r'the `(?P[\w.]+)` for "(?P[^"]*)"' + +# A step's optional trailing property list. +PROPERTIES = rf'(?: with (?P{PROPERTY_CLAUSES}))?' + +# The shape of each built-in step's text: what the step registers +# with pytest-bdd, and what `read` reads a step by. Named for the +# phrase that distinguishes the step. +APPLICATION_IS_UP = r'the (?:"(?P[^"]*)" )?application is up$' +AUTHENTICATED_USER_IS = r'the authenticated user is "(?P[^"]*)"$' +USER_IS_UNAUTHENTICATED = 'the user is unauthenticated' +BEARER_TOKEN_IS = r'the bearer token is "(?P[^"]*)"$' +SHARED_CONTEXT = 'a shared context' +GETS_CREATED_VIA = ( + r'(?:a|an) `(?P[\w.]+)` for "(?P[^"]*)" ' + rf'gets created via `(?P\w+)`{PROPERTIES}$' +) +GETS = ( + rf'{STATE} gets (?:a|an) `(?P\w+)`{PROPERTIES}' + r'(?: spawned with its task id saved as `(?P\w+)`)?$' +) +ATTEMPTS = rf'{STATE} attempts (?:a|an) `(?P\w+)`{PROPERTIES}$' +TASK_COMPLETES = ( + r'the `(?P\w+)` task with id "\$\{(?P\w+)\}" ' + r'of the `(?P[\w.]+)` completes within (?P.+)$' +) +ATTEMPT_ABORTS_WITH = ( + r'the attempt aborts with `(?P\w+)`' + rf'(?: with (?P{ASSERT_CLAUSES}))?$' +) +HAS = rf'`(?P\w+)` on {STATE} has (?P{ASSERT_CLAUSES})$' +EVENTUALLY_HAS = ( + rf'`(?P\w+)` on {STATE} ' + rf'eventually has (?P{ASSERT_CLAUSES}) within (?P.+)$' +) +HAS_SAVED_AS = ( + rf'`(?P\w+)` on {STATE} has (?P{SAVE_CLAUSES})$' +) +ABORTS_WITH = ( + rf'`(?P\w+)` on {STATE} aborts with `(?P\w+)`' + rf'(?: with (?P{ASSERT_CLAUSES}))?$' +) +RESULT_HAS = rf'the result has (?P{ASSERT_CLAUSES})$' +RESULTING_IS_SAVED_AS = ( + rf'the resulting `(?P{PATH})` is saved as `(?P\w+)`$' +) + +# The seconds a wait bound says, e.g. '30 seconds'. +_SECONDS = re.compile(r'(?P\d+(?:\.\d+)?) seconds?') + + +def _value(text: str) -> Value: + return Value(json=text) + + +def _state(match: re.Match[str]) -> State: + return State(type=match['state_type'], id=match['state_id']) + + +def _clauses(clauses: Optional[str]) -> list[str]: + """Each clause of a clause list, as written; none for an absent + list.""" + if clauses is None: + return [] + return [clause_match[0] for clause_match in re.finditer(CLAUSE, clauses)] + + +def _assignments(clauses: Optional[str]) -> list[Assignment]: + assignments = [] + for clause in _clauses(clauses): + property_match = PROPERTY_PATTERN.fullmatch(clause) + assert property_match is not None, clause + assignments.append( + Assignment( + path=property_match['path'], + value=_value(property_match['value']), + ) + ) + return assignments + + +def _assertions(clauses: Optional[str]) -> list[Assertion]: + assertions = [] + for clause in _clauses(clauses): + property_match = PROPERTY_PATTERN.fullmatch(clause) + if property_match is not None: + assertions.append( + Assertion( + equals=Equals( + path=property_match['path'], + value=_value(property_match['value']), + ) + ) + ) + continue + containing_match = CONTAINING_PATTERN.fullmatch(clause) + if containing_match is not None: + assertions.append( + Assertion( + containing=Containing( + path=containing_match['path'], + argument=_value(containing_match['argument']), + ) + ) + ) + continue + length_match = LENGTH_PATTERN.fullmatch(clause) + assert length_match is not None, clause + assertions.append( + Assertion( + of_length=OfLength( + path=length_match['path'], + length=_value(length_match['length']), + ) + ) + ) + return assertions + + +def _saves(clauses: Optional[str]) -> list[Save]: + saves = [] + for clause in _clauses(clauses): + save_match = SAVE_PATTERN.fullmatch(clause) + assert save_match is not None, clause + saves.append(Save(path=save_match['path'], name=save_match['saved'])) + return saves + + +def _seconds(within: str) -> Optional[float]: + """The seconds a wait bound says, and `None` for a bound that is + not of the form the grammar defines.""" + seconds_match = _SECONDS.fullmatch(within) + if seconds_match is None: + return None + return float(seconds_match['seconds']) + + +def parse(text: str) -> Optional[BuiltInSyntax]: + """The syntax tree of the step's text, and `None` for a text the + grammar does not define, such as a step a project defines + itself.""" + match = re.match(APPLICATION_IS_UP, text) + if match is not None: + application_is_up = ApplicationIsUp() + if match['name'] is not None: + application_is_up.name = match['name'] + return BuiltInSyntax(application_is_up=application_is_up) + match = re.match(AUTHENTICATED_USER_IS, text) + if match is not None: + return BuiltInSyntax( + authenticated_user_is=AuthenticatedUserIs( + user_id=match['user_id'] + ) + ) + if text == USER_IS_UNAUTHENTICATED: + return BuiltInSyntax(user_is_unauthenticated=UserIsUnauthenticated()) + match = re.match(BEARER_TOKEN_IS, text) + if match is not None: + return BuiltInSyntax( + bearer_token_is=BearerTokenIs(bearer_token=match['bearer_token']) + ) + if text == SHARED_CONTEXT: + return BuiltInSyntax(shared_context=SharedContext()) + match = re.match(GETS_CREATED_VIA, text) + if match is not None: + return BuiltInSyntax( + gets_created_via=GetsCreatedVia( + state=_state(match), + method=match['method'], + assignments=_assignments(match['clauses']), + ) + ) + match = re.match(GETS, text) + if match is not None: + gets = Gets( + state=_state(match), + method=match['method'], + assignments=_assignments(match['clauses']), + ) + if match['task'] is not None: + gets.task_id_saved_as = match['task'] + return BuiltInSyntax(gets=gets) + match = re.match(ATTEMPTS, text) + if match is not None: + return BuiltInSyntax( + attempts=Attempts( + state=_state(match), + method=match['method'], + assignments=_assignments(match['clauses']), + ) + ) + match = re.match(TASK_COMPLETES, text) + if match is not None: + seconds = _seconds(match['within']) + if seconds is None: + return None + return BuiltInSyntax( + task_completes=TaskCompletes( + method=match['method'], + task_id_saved_as=match['name'], + state_type=match['state_type'], + seconds=seconds, + ) + ) + match = re.match(ATTEMPT_ABORTS_WITH, text) + if match is not None: + return BuiltInSyntax( + attempt_aborts_with=AttemptAbortsWith( + error_type=match['error_type'], + assertions=_assertions(match['clauses']), + ) + ) + match = re.match(HAS, text) + if match is not None: + return BuiltInSyntax( + has=Has( + method=match['method'], + state=_state(match), + assertions=_assertions(match['clauses']), + ) + ) + match = re.match(EVENTUALLY_HAS, text) + if match is not None: + seconds = _seconds(match['within']) + if seconds is None: + return None + return BuiltInSyntax( + eventually_has=EventuallyHas( + method=match['method'], + state=_state(match), + assertions=_assertions(match['clauses']), + seconds=seconds, + ) + ) + match = re.match(HAS_SAVED_AS, text) + if match is not None: + return BuiltInSyntax( + has_saved_as=HasSavedAs( + method=match['method'], + state=_state(match), + saves=_saves(match['clauses']), + ) + ) + match = re.match(ABORTS_WITH, text) + if match is not None: + return BuiltInSyntax( + aborts_with=AbortsWith( + method=match['method'], + state=_state(match), + error_type=match['error_type'], + assertions=_assertions(match['clauses']), + ) + ) + match = re.match(RESULT_HAS, text) + if match is not None: + return BuiltInSyntax( + result_has=ResultHas(assertions=_assertions(match['clauses'])) + ) + match = re.match(RESULTING_IS_SAVED_AS, text) + if match is not None: + return BuiltInSyntax( + resulting_is_saved_as=ResultingIsSavedAs( + save=Save(path=match['property_name'], name=match['name']) + ) + ) + return None diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 96e5dfccf..05c1a104c 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -115,93 +115,40 @@ def application() -> Application: from reboot.bdd.fixtures import rbt as rbt from reboot.bdd.fixtures import reboot_event_loop as reboot_event_loop from reboot.bdd.fixtures import world as world +from reboot.bdd.grammar import ( + ABORTS_WITH, + APPLICATION_IS_UP, + ASSERT_CLAUSE, + ASSERT_CLAUSES, + ATTEMPT_ABORTS_WITH, + ATTEMPTS, + AUTHENTICATED_USER_IS, + BEARER_TOKEN_IS, + CLAUSE, + CONTAINING_PATTERN, + EVENTUALLY_HAS, + GETS, + GETS_CREATED_VIA, + HAS, + HAS_SAVED_AS, + LENGTH_PATTERN, + MIXED_CLAUSES, + PATH, + PROPERTY_CLAUSE, + PROPERTY_PATTERN, + RESULT_HAS, + RESULTING_IS_SAVED_AS, + SAVE_CLAUSE, + SAVE_CLAUSES, + SAVE_PATTERN, + SEPARATOR, + SHARED_CONTEXT, + TASK_COMPLETES, + USER_IS_UNAUTHENTICATED, +) from reboot.bdd.registry import client_types_by_name from typing import Any, Optional, Union, get_args, get_origin -# A property path in step text: a leading field, then dotted fields, -# bracketed list indices, and bracketed map keys. -_PATH = r'\w+(?:\.\w+|\[\d+\]|\["[^"]*"\])*' - -# One 'path=value' property clause: the property's path and value -# in backticks, the value being anything up to the closing backtick. -# The groupless form embeds in step patterns and deliberately also -# matches lexical near-misses (':' for '=', spaces around the '=', -# an empty value) so that those route to a step whose parser -# raises the fix; the compiled form is the strict shape, for -# extraction. -_PROPERTY_CLAUSE = rf'`{_PATH}\s*[:=]\s*[^`]*`' -_PROPERTY_PATTERN = re.compile(rf'`(?P{_PATH})=(?P\S[^`]*)`') - -# One saving clause: the (possibly dotted) property path in -# backticks, saved under a backticked name. The groupless form -# embeds in step patterns and deliberately also matches lexical -# near-misses ('saved to', a quoted or '$'-prefixed name) so that -# those route to a step whose parser raises the fix; the compiled -# form is the strict shape, for extraction. -_SAVE_CLAUSE = rf'`{_PATH}`\s+saved\s+(?:as|to)\s+(?:`\w+`|"?\$?\w+"?)' -_SAVE_PATTERN = re.compile(rf'`(?P{_PATH})` saved as `(?P\w+)`') - -# One containing clause: asserts a substring of a string, an element -# of a list, or a key of a map; the argument is a backticked JSON -# value, the same grammar as a property's value. The groupless form -# embeds in step patterns and also matches 'contains' and a bare -# argument, so those near-misses route to a step whose parser raises -# the fix; the compiled form is the strict shape, for extraction. -_CONTAINING_CLAUSE = ( - rf'`{_PATH}`\s+contain(?:s|ing)\s+' - r'(?:`[^`]*`|"(?:[^"\\]|\\.)*"|\$?[-+.\w{{}}]+)' -) -_CONTAINING_PATTERN = re.compile( - rf'`(?P{_PATH})` containing `(?P\S[^`]*)`' -) - -# One length clause: asserts the length of a string, list, or map; -# the length is a backticked value too, so it can recall a save. The -# groupless form embeds in step patterns and also matches a missing -# 'of' or a bare length, for diagnosis; the compiled form is the -# strict shape, for extraction. -_LENGTH_CLAUSE = rf'`{_PATH}`\s+(?:of\s+)?length\s+(?:`[^`]*`|\S+)' -_LENGTH_PATTERN = re.compile( - rf'`(?P{_PATH})` of length `(?P\S[^`]*)`' -) - -# What separates two clauses in step text: a comma, an 'and', or a -# comma followed by an 'and'. -_SEPARATOR = r'\s*(?:,\s*and|,|and)\s+' - -# A clause list of only 'path=value' properties: what a call's -# 'with' passes. -_PROPERTY_CLAUSES = rf'{_PROPERTY_CLAUSE}(?:{_SEPARATOR}{_PROPERTY_CLAUSE})*' - -# One asserting clause: an equality or a predicate. -_ASSERT_CLAUSE = ( - rf'(?:{_PROPERTY_CLAUSE}|{_CONTAINING_CLAUSE}|{_LENGTH_CLAUSE})' -) - -# A clause list of asserting clauses: what a Then 'has' and an -# abort's 'with' assert. -_ASSERT_CLAUSES = rf'{_ASSERT_CLAUSE}(?:{_SEPARATOR}{_ASSERT_CLAUSE})*' - -# A clause list of only saving clauses: what a Given or When 'has' -# saves. -_SAVE_CLAUSES = rf'{_SAVE_CLAUSE}(?:{_SEPARATOR}{_SAVE_CLAUSE})*' - -# A clause list mixing both kinds, which no step accepts; it exists -# so the mistake gets a pointed error instead of an unmatched step. -# A property value can never contain a backtick, so the lookaheads -# can only hit an actual clause of each kind. -_CLAUSE = rf'(?:{_ASSERT_CLAUSE}|{_SAVE_CLAUSE})' -_MIXED_CLAUSES = ( - rf'(?=.*`\s+saved\s)' - rf'(?=.*(?:`{_PATH}\s*[:=]|`{_PATH}`\s+contain|' - rf'`{_PATH}`\s+(?:of\s+)?length))' - rf'{_CLAUSE}(?:{_SEPARATOR}{_CLAUSE})*' -) - -# The 'the `Account` for "alice"' phrase naming the state a step acts -# on. -_STATE = r'the `(?P[\w.]+)` for "(?P[^"]*)"' - @dataclass(frozen=True) class Equals: @@ -242,9 +189,6 @@ class OfLength: # What one clause of an asserting list parses to. Assertion = Union[Equals, Containing, OfLength] -# A step's optional trailing property list. -_PROPERTIES = rf'(?: with (?P{_PROPERTY_CLAUSES}))?' - def _saved_value(world: World, name: str) -> JsonValue: """The saved value going by the given name; raises if there is @@ -278,11 +222,11 @@ def _maybe_saved(world: World, text: str) -> str: def _almost_property_message(clause: str) -> str: """The 'Almost' error for a property clause that is a lexical near-miss of `path=value`.""" - if re.match(rf'`{_PATH}\s*:', clause): + if re.match(rf'`{PATH}\s*:', clause): return f"Almost: say `path=value` with '=', not ':': {clause}" - if re.fullmatch(rf'`{_PATH}\s*=\s*`', clause): + if re.fullmatch(rf'`{PATH}\s*=\s*`', clause): return f"Almost: the value is missing: {clause}" - if re.match(rf'`{_PATH}\s+=', clause) or re.match(rf'`{_PATH}=\s', clause): + if re.match(rf'`{PATH}\s+=', clause) or re.match(rf'`{PATH}=\s', clause): return ( "Almost: write `path=value` without spaces around the " f"'=': {clause}" @@ -403,8 +347,8 @@ def _parse_assignments( assignments: list[Assignment] = [] if clauses is None: return assignments - for clause_match in re.finditer(_PROPERTY_CLAUSE, clauses): - property_match = _PROPERTY_PATTERN.fullmatch(clause_match[0]) + for clause_match in re.finditer(PROPERTY_CLAUSE, clauses): + property_match = PROPERTY_PATTERN.fullmatch(clause_match[0]) if property_match is None: raise ValueError(_almost_property_message(clause_match[0])) value = _parsed_value( @@ -433,9 +377,9 @@ def _parse_assertions( assertions: list[Assertion] = [] if clauses is None: return assertions - for clause_match in re.finditer(_ASSERT_CLAUSE, clauses): + for clause_match in re.finditer(ASSERT_CLAUSE, clauses): clause = clause_match[0] - containing_match = _CONTAINING_PATTERN.fullmatch(clause) + containing_match = CONTAINING_PATTERN.fullmatch(clause) if containing_match is not None: assertions.append( Containing( @@ -448,7 +392,7 @@ def _parse_assertions( ) ) continue - length_match = _LENGTH_PATTERN.fullmatch(clause) + length_match = LENGTH_PATTERN.fullmatch(clause) if length_match is not None: length = _parsed_value( world, @@ -471,7 +415,7 @@ def _parse_assertions( raise ValueError(_almost_containing_message(clause)) if re.search(r'\blength\b', clause): raise ValueError(_almost_length_message(clause)) - property_match = _PROPERTY_PATTERN.fullmatch(clause) + property_match = PROPERTY_PATTERN.fullmatch(clause) if property_match is None: raise ValueError(_almost_property_message(clause)) value = _parsed_value( @@ -495,8 +439,8 @@ def _parse_saves(clauses: str) -> dict[str, PropertyPath]: clause, so each clause is confirmed strict here, raising the fix.""" saves: dict[str, PropertyPath] = {} - for clause_match in re.finditer(_SAVE_CLAUSE, clauses): - save_match = _SAVE_PATTERN.fullmatch(clause_match[0]) + for clause_match in re.finditer(SAVE_CLAUSE, clauses): + save_match = SAVE_PATTERN.fullmatch(clause_match[0]) if save_match is None: raise ValueError(_almost_save_message(clause_match[0])) saves[save_match['saved']] = PropertyPath.create(save_match['path']) @@ -786,7 +730,7 @@ def _assert_properties( _assert_of_length(path, actual, length) -@given(parsers.re(r'the (?:"(?P[^"]*)" )?application is up$')) +@given(parsers.re(APPLICATION_IS_UP)) async def _the_application_is_up( rbt: Reboot, world: World, @@ -816,8 +760,8 @@ async def _the_application_is_up( world.name = request.node.name -@given(parsers.re(r'the authenticated user is "(?P[^"]*)"$')) -@when(parsers.re(r'the authenticated user is "(?P[^"]*)"$')) +@given(parsers.re(AUTHENTICATED_USER_IS)) +@when(parsers.re(AUTHENTICATED_USER_IS)) async def _the_authenticated_user_is(world: World, user_id: str) -> None: if world.rbt is None: raise ValueError( @@ -831,35 +775,25 @@ async def _the_authenticated_user_is(world: World, user_id: str) -> None: ) -@given('the user is unauthenticated') -@when('the user is unauthenticated') +@given(USER_IS_UNAUTHENTICATED) +@when(USER_IS_UNAUTHENTICATED) def _the_user_is_unauthenticated(world: World) -> None: world.set_bearer_token(None) -@given(parsers.re(r'the bearer token is "(?P[^"]*)"$')) -@when(parsers.re(r'the bearer token is "(?P[^"]*)"$')) +@given(parsers.re(BEARER_TOKEN_IS)) +@when(parsers.re(BEARER_TOKEN_IS)) def _the_bearer_token_is(world: World, bearer_token: str) -> None: world.set_bearer_token(_maybe_saved(world, bearer_token)) -@given('a shared context') +@given(SHARED_CONTEXT) def _a_shared_context(world: World) -> None: world.shared_context = world.context() -@given( - parsers.re( - r'(?:a|an) `(?P[\w.]+)` for "(?P[^"]*)" ' - rf'gets created via `(?P\w+)`{_PROPERTIES}$' - ) -) -@when( - parsers.re( - r'(?:a|an) `(?P[\w.]+)` for "(?P[^"]*)" ' - rf'gets created via `(?P\w+)`{_PROPERTIES}$' - ) -) +@given(parsers.re(GETS_CREATED_VIA)) +@when(parsers.re(GETS_CREATED_VIA)) async def _gets_created_via( world: World, state_type: str, @@ -885,19 +819,9 @@ async def _gets_created_via( ) from aborted -@given( - parsers.re( - rf'{_STATE} gets a `(?P\w+)`{_PROPERTIES}' - r'(?: spawned with its task id saved as `(?P\w+)`)?$' - ) -) -@when( - parsers.re( - rf'{_STATE} gets a `(?P\w+)`{_PROPERTIES}' - r'(?: spawned with its task id saved as `(?P\w+)`)?$' - ) -) -async def _gets_a( +@given(parsers.re(GETS)) +@when(parsers.re(GETS)) +async def _gets( world: World, state_type: str, state_id: str, @@ -935,8 +859,8 @@ async def _gets_a( ) from aborted -@when(parsers.re(rf'{_STATE} attempts a `(?P\w+)`{_PROPERTIES}$')) -async def _attempts_a( +@when(parsers.re(ATTEMPTS)) +async def _attempts( world: World, state_type: str, state_id: str, @@ -961,20 +885,8 @@ async def _attempts_a( world.aborted = aborted -@when( - parsers.re( - r'the `(?P\w+)` task with id "\$\{(?P\w+)\}" ' - r'of the `(?P[\w.]+)` completes within ' - r'(?P.+)$' - ) -) -@then( - parsers.re( - r'the `(?P\w+)` task with id "\$\{(?P\w+)\}" ' - r'of the `(?P[\w.]+)` completes within ' - r'(?P.+)$' - ) -) +@when(parsers.re(TASK_COMPLETES)) +@then(parsers.re(TASK_COMPLETES)) async def _the_saved_task_completes( world: World, method: str, @@ -1021,12 +933,7 @@ def _assert_aborted( _assert_properties(error, _parse_assertions(world, clauses)) -@then( - parsers.re( - r'the attempt aborts with `(?P\w+)`' - rf'(?: with (?P{_ASSERT_CLAUSES}))?$' - ) -) +@then(parsers.re(ATTEMPT_ABORTS_WITH)) def _the_attempt_aborts_with( world: World, error_type: str, @@ -1067,12 +974,7 @@ async def _read( ) from aborted -@then( - parsers.re( - rf'`(?P\w+)` on {_STATE} ' - rf'has (?P{_ASSERT_CLAUSES})$' - ) -) +@then(parsers.re(HAS)) async def _then_has( world: World, method: str, @@ -1084,13 +986,7 @@ async def _then_has( _assert_properties(response, _parse_assertions(world, clauses)) -@then( - parsers.re( - rf'`(?P\w+)` on {_STATE} ' - rf'eventually has (?P{_ASSERT_CLAUSES}) ' - r'within (?P.+)$' - ) -) +@then(parsers.re(EVENTUALLY_HAS)) async def _eventually_has( world: World, method: str, @@ -1149,18 +1045,8 @@ async def _eventually_has( await responses.aclose() -@given( - parsers.re( - rf'`(?P\w+)` on {_STATE} ' - rf'has (?P{_SAVE_CLAUSES})$' - ) -) -@when( - parsers.re( - rf'`(?P\w+)` on {_STATE} ' - rf'has (?P{_SAVE_CLAUSES})$' - ) -) +@given(parsers.re(HAS_SAVED_AS)) +@when(parsers.re(HAS_SAVED_AS)) async def _has_saved_as( world: World, method: str, @@ -1174,13 +1060,7 @@ async def _has_saved_as( world.saved[name] = _resolve_json_property(response_json, path) -@then( - parsers.re( - rf'`(?P\w+)` on {_STATE} ' - r'aborts with `(?P\w+)`' - rf'(?: with (?P{_ASSERT_CLAUSES}))?$' - ) -) +@then(parsers.re(ABORTS_WITH)) async def _aborts_with( world: World, method: str, @@ -1212,7 +1092,7 @@ async def _aborts_with( ) -@then(parsers.re(rf'the result has (?P{_ASSERT_CLAUSES})$')) +@then(parsers.re(RESULT_HAS)) def _the_result_has(world: World, clauses: str) -> None: assert world.response is not None, ( "Expected a preceding step to have made a call that returned " @@ -1221,18 +1101,8 @@ def _the_result_has(world: World, clauses: str) -> None: _assert_properties(world.response, _parse_assertions(world, clauses)) -@given( - parsers.re( - rf'the resulting `(?P{_PATH})` ' - r'is saved as `(?P\w+)`$' - ) -) -@when( - parsers.re( - rf'the resulting `(?P{_PATH})` ' - r'is saved as `(?P\w+)`$' - ) -) +@given(parsers.re(RESULTING_IS_SAVED_AS)) +@when(parsers.re(RESULTING_IS_SAVED_AS)) def _the_resulting_property_is_saved_as( world: World, property_name: str, @@ -1269,7 +1139,7 @@ def _almost_completes_needs_within() -> None: ) -@then(parsers.re(rf'.+ eventually has {_ASSERT_CLAUSES}$')) +@then(parsers.re(rf'.+ eventually has {ASSERT_CLAUSES}$')) def _almost_eventually_needs_within() -> None: raise ValueError( "Almost: say how long 'eventually has' keeps its reactive " @@ -1277,7 +1147,7 @@ def _almost_eventually_needs_within() -> None: ) -@then(parsers.re(rf'.+(? None: raise ValueError( "Almost: 'within' goes with 'eventually has'; a plain 'has' " @@ -1315,8 +1185,8 @@ def _almost_eventually_under_given_or_when() -> None: ) -@given(parsers.re(rf'.+ has {_ASSERT_CLAUSES}$')) -@when(parsers.re(rf'.+ has {_ASSERT_CLAUSES}$')) +@given(parsers.re(rf'.+ has {ASSERT_CLAUSES}$')) +@when(parsers.re(rf'.+ has {ASSERT_CLAUSES}$')) def _almost_asserting_under_given_or_when() -> None: raise ValueError( "Almost: a Given or When 'has' saves, e.g. `path` saved as " @@ -1324,7 +1194,7 @@ def _almost_asserting_under_given_or_when() -> None: ) -@then(parsers.re(rf'.+ has {_SAVE_CLAUSES}$')) +@then(parsers.re(rf'.+ has {SAVE_CLAUSES}$')) def _almost_saving_under_then() -> None: raise ValueError( "Almost: a Then 'has' asserts `path=value` properties; " @@ -1332,9 +1202,9 @@ def _almost_saving_under_then() -> None: ) -@given(parsers.re(rf'.+ has {_MIXED_CLAUSES}$')) -@when(parsers.re(rf'.+ has {_MIXED_CLAUSES}$')) -@then(parsers.re(rf'.+ has {_MIXED_CLAUSES}$')) +@given(parsers.re(rf'.+ has {MIXED_CLAUSES}$')) +@when(parsers.re(rf'.+ has {MIXED_CLAUSES}$')) +@then(parsers.re(rf'.+ has {MIXED_CLAUSES}$')) def _almost_mixing_clauses() -> None: raise ValueError( "Almost: a 'has' list is all one kind; a Given or When " @@ -1345,20 +1215,20 @@ def _almost_mixing_clauses() -> None: @given( parsers.re( - rf'.+ with (?=.*`\s+saved\s){_CLAUSE}' - rf'(?:{_SEPARATOR}{_CLAUSE})*$' + rf'.+ with (?=.*`\s+saved\s){CLAUSE}' + rf'(?:{SEPARATOR}{CLAUSE})*$' ) ) @when( parsers.re( - rf'.+ with (?=.*`\s+saved\s){_CLAUSE}' - rf'(?:{_SEPARATOR}{_CLAUSE})*$' + rf'.+ with (?=.*`\s+saved\s){CLAUSE}' + rf'(?:{SEPARATOR}{CLAUSE})*$' ) ) @then( parsers.re( - rf'.+ with (?=.*`\s+saved\s){_CLAUSE}' - rf'(?:{_SEPARATOR}{_CLAUSE})*$' + rf'.+ with (?=.*`\s+saved\s){CLAUSE}' + rf'(?:{SEPARATOR}{CLAUSE})*$' ) ) def _almost_saving_in_with() -> None: @@ -1371,13 +1241,13 @@ def _almost_saving_in_with() -> None: @given( parsers.re( rf'.+ with (?=.*`\s+contain|.*`\s+(?:of\s+)?length)' - rf'{_ASSERT_CLAUSES}$' + rf'{ASSERT_CLAUSES}$' ) ) @when( parsers.re( rf'.+ with (?=.*`\s+contain|.*`\s+(?:of\s+)?length)' - rf'{_ASSERT_CLAUSES}$' + rf'{ASSERT_CLAUSES}$' ) ) def _almost_predicate_in_call_with() -> None: diff --git a/reboot/dashboard/backend/BUILD.bazel b/reboot/dashboard/backend/BUILD.bazel index bd7edaf6b..1598273d4 100644 --- a/reboot/dashboard/backend/BUILD.bazel +++ b/reboot/dashboard/backend/BUILD.bazel @@ -102,6 +102,21 @@ py_library( ], ) +py_library( + name = "behaviors_watcher_py", + srcs = ["behaviors_watcher.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + "//rbt/dashboard/v1:dashboard_py_reboot", + "//rbt/v1alpha1/bdd:feature_py_proto", + "//reboot/aio:contexts_py", + "//reboot/aio:workflows_py", + "//reboot/bdd:feature_py", + "//reboot/cli/common:watch_py", + ], +) + py_library( name = "code_watcher_py", srcs = ["code_watcher.py"], @@ -125,6 +140,7 @@ py_library( visibility = ["//visibility:public"], deps = [ ":api_watcher_py", + ":behaviors_watcher_py", ":code_watcher_py", ":constants_py", ":needs_generate_reason_py", diff --git a/reboot/dashboard/backend/behaviors_watcher.py b/reboot/dashboard/backend/behaviors_watcher.py new file mode 100644 index 000000000..9c2a233e2 --- /dev/null +++ b/reboot/dashboard/backend/behaviors_watcher.py @@ -0,0 +1,134 @@ +"""Watches the developer's `.feature` files and updates what they +declare. + +Every feature file under the working directory is read and parsed +again on every change: the files are small, and parsing them all is +cheaper than tracking which one an event was about, so a burst of +saves loses nothing however many events the watch failed to hear. + +Each file is parsed by `reboot.bdd.feature`, whose package imports +pytest-bdd and whose parser is `gherkin-official`; both arrive with +`reboot[pytest-bdd]`. Without the extra, each feature file found is +recorded with an error saying to install it, so the page can say +why it shows no behaviors. +""" +from functools import partial +from pathlib import Path +from rbt.dashboard.v1.dashboard_rbt import Dashboard +from rbt.v1alpha1.bdd.feature_pb2 import Feature +from reboot.aio.contexts import WorkflowContext +from reboot.aio.workflows import at_least_once +from reboot.cli.common.watch import file_watcher +from typing import Mapping + +try: + from reboot.bdd import feature + _extra_installed = True +except ImportError: + _extra_installed = False + +# The glob every scenario file matches, which is the extension +# `pytest-bdd` and every other Gherkin tool reads. +FEATURE_GLOB = '**/*.feature' + + +def _feature_files(directory: Path) -> list[Path]: + """Every feature file under the working directory, sorted, with + hidden directories and `node_modules` left out: a `.venv` or + `node_modules` carries installed packages' feature files, which + are not the developer's.""" + return sorted( + path for path in directory.glob(FEATURE_GLOB) if not any( + part == 'node_modules' or part.startswith('.') + for part in path.relative_to(directory).parts + ) + ) + + +async def _read_and_parse(*, directory: Path) -> dict[str, Feature]: + """What every feature file under the working directory declares + now, keyed by path relative to it, with a file that could not be + read or parsed carrying why instead. + + Memoized by the caller per iteration, so everything returned is + a plain value pickle can keep. + """ + features: dict[str, Feature] = {} + for path in _feature_files(directory): + filename = str(path.relative_to(directory)) + if not _extra_installed: + features[filename] = Feature( + error='Reading `.feature` files needs the packages ' + '`reboot[pytest-bdd]` installs; install the extra to see ' + 'behaviors here.' + ) + continue + try: + source = path.read_text() + except OSError as error: + features[filename] = Feature(error=str(error)) + continue + parsed = feature.parse(source) + if parsed is not None: + features[filename] = parsed + return features + + +async def watch(context: WorkflowContext) -> None: + """Keeps the behaviors state matching what the feature files + declare, until cancelled. + + The working directory is where `rbt dashboard` runs, which is + where the developer's project is: feature files have no `.rbtrc` + entry naming where they live, so everywhere under the project is + where to look. + """ + directory = Path.cwd() + + # What a previous run recorded: an unchanged set of files calls + # for no update after a restart. + state = await Dashboard.ref().always().read(context) + features: Mapping[str, Feature] = dict(state.features) + + # Whether this process has yet to wait for a save: a restart is + # itself a reason to read the files again. + restarted = True + + with file_watcher() as watcher: + async for _ in context.loop('Read what changed'): + # The loop opens the watch before it reads anything, so a + # save made during a read resolves `event` instead of + # firing between watches, where nothing would notice it. + async with watcher.watch( + [FEATURE_GLOB], + root_dir=str(directory), + ) as event: + + # Memoized per iteration. + features_now = await at_least_once( + 'Read and parse', + context, + partial(_read_and_parse, directory=directory), + ) + + # An update wakes every browser reading `Get`, so one + # is only made for a difference. + if features_now != features: + await Dashboard.ref( + ).per_iteration('Update').UpdateBehaviors( + context, + features=features_now, + ) + features = features_now + + # A restarted workflow may be in an iteration that has + # already memoized `_read_and_parse`, so it goes to the + # next iteration immediately rather than waiting on an + # `event` while changes made since sit unread. Worst + # case `features_now` matched `features` and the next + # iteration just waits on its own `event`. + if restarted: + restarted = False + continue + + await event diff --git a/reboot/dashboard/backend/main.py b/reboot/dashboard/backend/main.py index 3088aedef..18fce2a89 100644 --- a/reboot/dashboard/backend/main.py +++ b/reboot/dashboard/backend/main.py @@ -115,6 +115,8 @@ async def initialize(context: InitializeContext) -> None: _ = await dashboard.idempotently().spawn().WatchCode(context) + _ = await dashboard.idempotently().spawn().WatchBehaviors(context) + async def main(): await application().run() diff --git a/reboot/dashboard/backend/servicers.py b/reboot/dashboard/backend/servicers.py index 2c0c7f54f..54c56209c 100644 --- a/reboot/dashboard/backend/servicers.py +++ b/reboot/dashboard/backend/servicers.py @@ -6,6 +6,8 @@ DashboardGetResponse, DashboardUpdateApiRequest, DashboardUpdateApiResponse, + DashboardUpdateBehaviorsRequest, + DashboardUpdateBehaviorsResponse, DashboardUpdateCodeRequest, DashboardUpdateCodeResponse, PreferencesGetRequest, @@ -26,7 +28,11 @@ WorkflowContext, WriterContext, ) -from reboot.dashboard.backend import api_watcher, code_watcher +from reboot.dashboard.backend import ( + api_watcher, + behaviors_watcher, + code_watcher, +) from reboot.dashboard.backend.constants import ( CHANGELOG_ID, ENVVAR_RBT_API_DIRECTORY, @@ -63,6 +69,7 @@ async def Get( servicers=self.state.servicers, generated=self.state.generated, needs_generate_reason=needs_generate_reason(self.state), + features=self.state.features, ) @classmethod @@ -95,11 +102,9 @@ async def UpdateCode( del self.state.servicers[:] self.state.servicers.extend(request.servicers) self.state.code_files.clear() - for filename, file in request.code_files.items(): - self.state.code_files[filename].CopyFrom(file) + self.state.code_files.MergeFrom(request.code_files) self.state.generated.clear() - for filename, generated in request.generated.items(): - self.state.generated[filename].CopyFrom(generated) + self.state.generated.MergeFrom(request.generated) if len(request.changes) > 0: await OrderedMap.ref(CHANGELOG_ID).Insert( @@ -141,6 +146,29 @@ async def WatchCode( return Dashboard.WatchCodeResponse() + async def UpdateBehaviors( + self, + context: WriterContext, + request: DashboardUpdateBehaviorsRequest, + ) -> DashboardUpdateBehaviorsResponse: + """Replaces what the developer's `.feature` files declare.""" + self.state.features.clear() + self.state.features.MergeFrom(request.features) + + return DashboardUpdateBehaviorsResponse() + + @classmethod + async def WatchBehaviors( + cls, + context: WorkflowContext, + request: Dashboard.WatchBehaviorsRequest, + ) -> Dashboard.WatchBehaviorsResponse: + """Returns only when the dashboard stops, parsing the + developer's `.feature` files whenever they change.""" + await behaviors_watcher.watch(context) + + return Dashboard.WatchBehaviorsResponse() + async def UpdateApi( self, context: TransactionContext, @@ -154,13 +182,11 @@ async def UpdateApi( else: self.state.ClearField('error') self.state.api_files.clear() - for filename, file in request.api_files.items(): - self.state.api_files[filename].CopyFrom(file) + self.state.api_files.MergeFrom(request.api_files) self.state.apis.clear() - for filename, api in request.apis.items(): - self.state.apis[filename].CopyFrom(api) + self.state.apis.MergeFrom(request.apis) self.state.api_digests.clear() - self.state.api_digests.update(request.api_digests) + self.state.api_digests.MergeFrom(request.api_digests) if len(request.changes) > 0: await OrderedMap.ref(CHANGELOG_ID).Insert( diff --git a/reboot/dashboard/web/BUILD.bazel b/reboot/dashboard/web/BUILD.bazel index 73574eea2..458fc4bf3 100644 --- a/reboot/dashboard/web/BUILD.bazel +++ b/reboot/dashboard/web/BUILD.bazel @@ -9,6 +9,7 @@ ts_config( ts_project( name = "dashboard_ts", srcs = [ + "src/behaviors.ts", "src/callgraph.ts", "src/changelog.ts", "src/constants.ts", @@ -38,6 +39,8 @@ ts_project( "//rbt/std/collections/ordered_map/v1:ordered_map_js_reboot_react", "//rbt/v1alpha1/api:api_js_proto", "//rbt/v1alpha1/api:schema_js_proto", + "//rbt/v1alpha1/bdd:feature_js_proto", + "//rbt/v1alpha1/bdd:grammar_js_proto", ], ) diff --git a/reboot/dashboard/web/dashboard.css b/reboot/dashboard/web/dashboard.css index abfb4560f..dd86b769e 100644 --- a/reboot/dashboard/web/dashboard.css +++ b/reboot/dashboard/web/dashboard.css @@ -1630,3 +1630,240 @@ header h1 { height: 8px; color: hsl(211 25% 60%); } + +/* --- Behaviors --- */ + +.scenarios { + display: flex; + flex-direction: column; + gap: 14px; +} + +.scenario { + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--card)); +} + +.scenario-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 14px 18px 12px; + cursor: pointer; + user-select: none; +} + +.scenario-caret { + font-family: ui-monospace, Menlo, monospace; + font-size: 11px; + color: hsl(240 3.8% 55%); +} + +.scenario-name { + font-size: 14px; + font-weight: 600; +} + +/* Slate: the keyword is structure, not one of the four kinds, so it + stays as muted as the base pill while keeping the pill shape that + makes the rows scan as a column. */ +.scenario-keyword { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 92px; + font-family: ui-monospace, Menlo, monospace; + font-size: 10px; + padding: 2px 8px; + border-radius: 999px; + background: hsl(var(--muted)); + border: 1px solid hsl(240 5.9% 84%); + color: hsl(var(--muted-foreground)); +} + +/* The same open-and-close animation as `.method-detail`, on the + scenario's own class so that the subgrid layout `.methods` gives + its rows never applies here. */ +.scenario-detail { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: grid-template-rows 240ms cubic-bezier(0.32, 0.72, 0, 1), + opacity 120ms ease-out; +} + +.scenario-detail-inner { + overflow: hidden; + display: flex; + flex-direction: column; + gap: 12px; +} + +.scenario.is-expanded .scenario-detail { + grid-template-rows: 1fr; + opacity: 1; + transition: grid-template-rows 240ms cubic-bezier(0.32, 0.72, 0, 1), + opacity 180ms ease-in 60ms; +} + +@media (prefers-reduced-motion: reduce) { + .scenario-detail, + .scenario.is-expanded .scenario-detail { + transition-duration: 1ms; + } +} + +.scenario-detail-inner > :first-child { + margin-top: 0; +} + +.scenario-detail-inner > * { + margin: 0 18px; +} + +.scenario-detail-inner > :last-child { + margin-bottom: 14px; +} + +.steps { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* The keyword in a gutter of its own, right-aligned, so every step's + text starts at one x and the keywords read down the side; `And` + and `But` continue the step above, so theirs is set lighter. */ +.step { + display: grid; + grid-template-columns: 4.5em minmax(0, 1fr); + column-gap: 10px; + font-size: 13px; + line-height: 1.55; + color: hsl(var(--prose)); +} + +.step-keyword { + text-align: right; + font-weight: 600; + color: hsl(211 72% 32%); +} + +.step-continuation { + font-weight: 500; + color: hsl(211 30% 60%); +} + +.step-text { + min-width: 0; +} + +/* A background's steps, shown as part of the scenario they run + before: present, but not what the scenario is about. */ +.step-background { + opacity: 0.55; +} + +/* Each clause of a long clause list on a line of its own, indented + under the step's text. */ +.clause { + padding-left: 1.5em; +} + +/* What the grammar read a span of a step as. A span is set as code + whatever its role; the role picks its colour. */ +.step code.span { + font-family: ui-monospace, Menlo, monospace; + font-size: 0.9em; + padding: 0 4px; + border-radius: 4px; + background: hsl(var(--surface-sunken)); + border: 1px solid hsl(var(--border-soft)); +} + +/* Blue: what the step names in the API, which links there. */ +.step code.span-state-type, +.step code.span-method { + color: hsl(211 72% 32%); +} + +/* Violet: a property being set, read, or saved. */ +.step code.span-property-path { + color: hsl(275 50% 40%); +} + +/* Teal: a literal value. */ +.step code.span-value { + color: hsl(166 55% 27%); +} + +/* A saved value, where it is saved and where it is recalled, and a + state id, wherever it is named, each in a hue of its own + (`--hue`, set per name by the page), so the same one reads as one + thing across a scenario and the next reads as another; hovering + any lights up every span about the same one. The fallback hue is + for a span the page gave none. */ +.step code.span-saved-name, +.step code.span-variable, +.step code.span-state-id { + color: hsl(var(--hue, 28) 70% 32%); + background: hsl(var(--hue, 28) 85% 55% / 0.14); + border-color: hsl(var(--hue, 28) 70% 50% / 0.4); + cursor: default; +} + +.step code.is-related { + background: hsl(var(--hue, 28) 85% 55% / 0.45); + border-color: hsl(var(--hue, 28) 70% 40%); +} + +/* Magenta: an error type. */ +.step code.span-error-type { + color: hsl(318 55% 38%); +} + +/* Muted: who is calling, which application, and how long to wait. */ +.step code.span-user, +.step code.span-application, +.step code.span-duration { + color: hsl(var(--muted-foreground)); +} + +.gherkin-table { + margin-top: 6px; + border-collapse: collapse; + font-family: ui-monospace, Menlo, monospace; + font-size: 12px; +} + +.gherkin-table td { + border: 1px solid hsl(var(--border-soft)); + padding: 3px 10px; +} + +/* The first row of an examples table is its header. */ +.examples .gherkin-table tr:first-child td { + font-weight: 600; + background: hsl(var(--surface-sunken)); +} + +.rule { + margin-top: 26px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.rule-heading { + display: flex; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; +} + +.rule-heading h3 { + margin: 0; + font-size: 17px; +} diff --git a/reboot/dashboard/web/src/behaviors.ts b/reboot/dashboard/web/src/behaviors.ts new file mode 100644 index 000000000..ebf50cb96 --- /dev/null +++ b/reboot/dashboard/web/src/behaviors.ts @@ -0,0 +1,513 @@ +// What the behaviors page derives from the parsed `.feature` files: +// their order, their scenario counts, and where a backticked span of +// a step links. + +import type * as feature_pb from "../../../../rbt/v1alpha1/bdd/feature_pb"; +import type * as grammar_pb from "../../../../rbt/v1alpha1/bdd/grammar_pb"; +import type { APIs } from "./link_properties_to_data_types"; +import { qualifiedName } from "./link_properties_to_data_types"; + +export type Features = { [filename: string]: feature_pb.Feature }; + +// One feature file, with the path the state keys it by, which is the +// path the developer would open. +export interface FeatureEntry { + filename: string; + feature: feature_pb.Feature; +} + +export const sortedFeatures = (features: Features): FeatureEntry[] => + Object.entries(features) + .map(([filename, feature]) => ({ filename, feature })) + .sort((a, b) => a.filename.localeCompare(b.filename)); + +// Every scenario of a feature: the ones that belong to it directly, +// then each rule's, which is the order they are written in the file. +export const scenariosOfFeature = ( + feature: feature_pb.Feature +): feature_pb.Scenario[] => [ + ...feature.scenarios, + ...feature.rules.flatMap((rule) => rule.scenarios), +]; + +// The directory a feature file is in, which is how the sidebar groups +// features, the way packages group types. +export const directoryOfFeature = (filename: string): string => { + const slash = filename.lastIndexOf("/"); + return slash === -1 ? "." : filename.slice(0, slash); +}; + +// Where the backticked spans of steps can link: each state type's +// short name mapped to its id on the state page, and each method name +// mapped to every state type declaring one by that name. +export interface StepLinks { + stateTypes: Map; + methods: Map; +} + +export const stepLinks = (apis: APIs): StepLinks => { + // A short name two state types share cannot say which one a step + // means, so it is mapped to `null` here and dropped below. + const stateTypes = new Map(); + const methods = new Map(); + for (const api of Object.values(apis)) { + for (const stateType of api.stateTypes) { + const id = qualifiedName({ api, stateType }); + stateTypes.set( + stateType.name, + stateTypes.has(stateType.name) ? null : id + ); + for (const method of stateType.methods) { + const candidates = methods.get(method.name) ?? []; + candidates.push({ + stateType: stateType.name, + id: `${id}.${method.name}`, + }); + methods.set(method.name, candidates); + } + } + } + return { + stateTypes: new Map( + [...stateTypes.entries()].flatMap(([name, id]) => + id === null ? [] : [[name, id] as [string, string]] + ) + ), + methods, + }; +}; + +// The id on the state page a method links to, and `undefined` for a +// method name no state type declares, or one several declare when +// `stateType`, the state type the step names, is not among them: the +// page never guesses which state type a step means. +export const linkOfMethod = ( + method: string, + stateType: string | undefined, + links: StepLinks +): string | undefined => { + const candidates = links.methods.get(method); + if (candidates === undefined) { + return undefined; + } + if (candidates.length === 1) { + return candidates[0].id; + } + const named = candidates.filter( + (candidate) => candidate.stateType === stateType + ); + return named.length === 1 ? named[0].id : undefined; +}; + +// The id on the state page one backticked span of a step the grammar +// does not define links to, and `undefined` for a span that is +// neither a state type nor a method: the step's own text is the only +// clue to which state type a method name means. +export const linkOfCodeSpan = ( + span: string, + stepText: string, + links: StepLinks +): string | undefined => { + const stateType = links.stateTypes.get(span); + if (stateType !== undefined) { + return stateType; + } + const candidates = links.methods.get(span) ?? []; + const named = candidates.filter((candidate) => + stepText.includes("`" + candidate.stateType + "`") + ); + return linkOfMethod( + span, + named.length === 1 ? named[0].stateType : undefined, + links + ); +}; + +// What a run of a printed step is to the grammar, which is how the +// page sets it. `text` is the grammar's own words. +export type Role = + | "text" + | "state-type" + | "state-id" + | "method" + | "property-path" + | "value" + | "variable" + | "saved-name" + | "error-type" + | "user" + | "application" + | "duration"; + +export interface Span { + text: string; + role: Role; +} + +// A step printed from its syntax tree: the spans before its clause +// list, each clause of the list, and the spans after. A step without +// a clause list has only a head. +export interface Printed { + head: Span[]; + clauses: Span[][]; + tail: Span[]; +} + +const text = (words: string): Span => ({ text: words, role: "text" }); + +// A variable in a step's text, `${name}`, whose saved value is spliced +// in when the scenario runs. +const VARIABLE = /\$\{\w+\}/g; + +// Text that may hold variables, as spans: each variable as one, and +// the text between as spans of `role`. +const spansOfText = (words: string, role: Role): Span[] => { + const spans: Span[] = []; + let at = 0; + for (const match of words.matchAll(VARIABLE)) { + if (match.index > at) { + spans.push({ text: words.slice(at, match.index), role }); + } + spans.push({ text: match[0], role: "variable" }); + at = match.index + match[0].length; + } + if (at < words.length) { + spans.push({ text: words.slice(at), role }); + } + return spans; +}; + +const spansOfValue = (value: grammar_pb.Value | undefined): Span[] => + spansOfText(value?.json ?? "", "value"); + +const spansOfStateId = (id: string): Span[] => spansOfText(id, "state-id"); + +// 'the `Account` for "alice"', as the grammar's `STATE` phrase. +const spansOfState = (state: grammar_pb.State | undefined): Span[] => [ + text("the "), + { text: state?.type ?? "", role: "state-type" }, + text(" for "), + ...spansOfStateId(state?.id ?? ""), +]; + +const spansOfAssignment = (assignment: grammar_pb.Assignment): Span[] => [ + { text: assignment.path, role: "property-path" }, + text("="), + ...spansOfValue(assignment.value), +]; + +const spansOfAssertion = (assertion: grammar_pb.Assertion): Span[] => { + switch (assertion.assertion.case) { + case "equals": + return [ + { text: assertion.assertion.value.path, role: "property-path" }, + text("="), + ...spansOfValue(assertion.assertion.value.value), + ]; + case "containing": + return [ + { text: assertion.assertion.value.path, role: "property-path" }, + text(" containing "), + ...spansOfValue(assertion.assertion.value.argument), + ]; + case "ofLength": + return [ + { text: assertion.assertion.value.path, role: "property-path" }, + text(" of length "), + ...spansOfValue(assertion.assertion.value.length), + ]; + default: + return []; + } +}; + +const spansOfSave = (save: grammar_pb.Save): Span[] => [ + { text: save.path, role: "property-path" }, + text(" saved as "), + { text: save.name, role: "saved-name" }, +]; + +const spanOfSeconds = (seconds: number): Span => ({ + text: `${seconds} ${seconds === 1 ? "second" : "seconds"}`, + role: "duration", +}); + +// The article before a name, with its trailing space: `an` before a +// vowel, the way the grammar accepts either and English reads. +const articleOf = (name: string): string => + /^[AEIOUaeiou]/.test(name) ? "an " : "a "; + +// A clause list with its introducing word, or nothing for an empty +// list, since the grammar leaves the word out with it. +const withClauses = ( + word: string, + clauses: Span[][] +): { head: Span[]; clauses: Span[][] } => + clauses.length === 0 + ? { head: [], clauses: [] } + : { head: [text(` ${word} `)], clauses }; + +// A built-in step printed from its syntax tree, the way the grammar +// spells it. The grammar is strict enough that this is the step as +// written, but for `,` against `and` between clauses and `a` against +// `an`. +export const printBuiltInSyntax = ( + syntax: grammar_pb.BuiltInSyntax +): Printed => { + const step = syntax.step; + switch (step.case) { + case "applicationIsUp": + return { + head: + step.value.name === undefined + ? [text("the application is up")] + : [ + text("the "), + { text: `"${step.value.name}"`, role: "application" }, + text(" application is up"), + ], + clauses: [], + tail: [], + }; + case "authenticatedUserIs": + return { + head: [ + text("the authenticated user is "), + { text: `"${step.value.userId}"`, role: "user" }, + ], + clauses: [], + tail: [], + }; + case "userIsUnauthenticated": + return { + head: [text("the user is unauthenticated")], + clauses: [], + tail: [], + }; + case "bearerTokenIs": + return { + head: [ + text("the bearer token is "), + { text: `"${step.value.bearerToken}"`, role: "value" }, + ], + clauses: [], + tail: [], + }; + case "sharedContext": + return { head: [text("a shared context")], clauses: [], tail: [] }; + case "getsCreatedVia": { + const state = step.value.state; + const article = articleOf(state?.type ?? ""); + const clauses = withClauses( + "with", + step.value.assignments.map(spansOfAssignment) + ); + return { + head: [ + text(article), + { text: state?.type ?? "", role: "state-type" }, + text(" for "), + ...spansOfStateId(state?.id ?? ""), + text(" gets created via "), + { text: step.value.method, role: "method" }, + ...clauses.head, + ], + clauses: clauses.clauses, + tail: [], + }; + } + case "gets": { + const clauses = withClauses( + "with", + step.value.assignments.map(spansOfAssignment) + ); + return { + head: [ + ...spansOfState(step.value.state), + text(` gets ${articleOf(step.value.method)}`), + { text: step.value.method, role: "method" }, + ...clauses.head, + ], + clauses: clauses.clauses, + tail: + step.value.taskIdSavedAs === undefined + ? [] + : [ + text(" spawned with its task id saved as "), + { text: step.value.taskIdSavedAs, role: "saved-name" }, + ], + }; + } + case "attempts": { + const clauses = withClauses( + "with", + step.value.assignments.map(spansOfAssignment) + ); + return { + head: [ + ...spansOfState(step.value.state), + text(` attempts ${articleOf(step.value.method)}`), + { text: step.value.method, role: "method" }, + ...clauses.head, + ], + clauses: clauses.clauses, + tail: [], + }; + } + case "taskCompletes": + return { + head: [ + text("the "), + { text: step.value.method, role: "method" }, + text(" task with id "), + { text: "${" + step.value.taskIdSavedAs + "}", role: "variable" }, + text(" of the "), + { text: step.value.stateType, role: "state-type" }, + text(" completes within "), + spanOfSeconds(step.value.seconds), + ], + clauses: [], + tail: [], + }; + case "attemptAbortsWith": { + const clauses = withClauses( + "with", + step.value.assertions.map(spansOfAssertion) + ); + return { + head: [ + text("the attempt aborts with "), + { text: step.value.errorType, role: "error-type" }, + ...clauses.head, + ], + clauses: clauses.clauses, + tail: [], + }; + } + case "has": + return { + head: [ + { text: step.value.method, role: "method" }, + text(" on "), + ...spansOfState(step.value.state), + text(" has "), + ], + clauses: step.value.assertions.map(spansOfAssertion), + tail: [], + }; + case "eventuallyHas": + return { + head: [ + { text: step.value.method, role: "method" }, + text(" on "), + ...spansOfState(step.value.state), + text(" eventually has "), + ], + clauses: step.value.assertions.map(spansOfAssertion), + tail: [text(" within "), spanOfSeconds(step.value.seconds)], + }; + case "hasSavedAs": + return { + head: [ + { text: step.value.method, role: "method" }, + text(" on "), + ...spansOfState(step.value.state), + text(" has "), + ], + clauses: step.value.saves.map(spansOfSave), + tail: [], + }; + case "abortsWith": { + const clauses = withClauses( + "with", + step.value.assertions.map(spansOfAssertion) + ); + return { + head: [ + { text: step.value.method, role: "method" }, + text(" on "), + ...spansOfState(step.value.state), + text(" aborts with "), + { text: step.value.errorType, role: "error-type" }, + ...clauses.head, + ], + clauses: clauses.clauses, + tail: [], + }; + } + case "resultHas": + return { + head: [text("the result has ")], + clauses: step.value.assertions.map(spansOfAssertion), + tail: [], + }; + case "resultingIsSavedAs": + return { + head: [ + text("the resulting "), + { text: step.value.save?.path ?? "", role: "property-path" }, + text(" is saved as "), + { text: step.value.save?.name ?? "", role: "saved-name" }, + ], + clauses: [], + tail: [], + }; + default: + return { head: [], clauses: [], tail: [] }; + } +}; + +// Every span of a printed step, in order. +export const spansOfPrinted = (printed: Printed): Span[] => [ + ...printed.head, + ...printed.clauses.flat(), + ...printed.tail, +]; + +// The spans a scenario sets in a hue of their own: each saved value, +// where it is saved and where it is recalled, and each state id, +// wherever it is named. What the hue is keyed by says which of the +// two a span is, since a state id and a saved name may be spelled +// the same. +export const hueKeyOfSpan = (span: Span): string | undefined => + span.role === "state-id" + ? `state:${span.text}` + : span.role === "variable" + ? `saved:${span.text.slice(2, -1)}` + : span.role === "saved-name" + ? `saved:${span.text}` + : undefined; + +// Hues far enough apart to tell one saved value from the next, and +// one state id from the next; the two palettes share no hue, and +// both keep clear of the hues the other roles are set in. +const SAVED_HUES = [28, 350, 110, 190, 300, 55]; +const STATE_ID_HUES = [150, 245, 80, 325, 5, 215]; + +// The hue each saved value and each state id of a scenario is set +// in, keyed the way `hueKeyOfSpan` keys them and assigned in the +// order the keys first appear across the steps, each kind from its +// own palette. +export const huesOfSpans = (steps: feature_pb.Step[]): Map => { + const hues = new Map(); + const counts = { saved: 0, state: 0 }; + for (const step of steps) { + if (step.builtIn === undefined) { + continue; + } + for (const span of spansOfPrinted(printBuiltInSyntax(step.builtIn))) { + const key = hueKeyOfSpan(span); + if (key === undefined || hues.has(key)) { + continue; + } + if (key.startsWith("state:")) { + hues.set(key, STATE_ID_HUES[counts.state % STATE_ID_HUES.length]); + counts.state += 1; + } else { + hues.set(key, SAVED_HUES[counts.saved % SAVED_HUES.length]); + counts.saved += 1; + } + } + } + return hues; +}; diff --git a/reboot/dashboard/web/src/main.tsx b/reboot/dashboard/web/src/main.tsx index 6ab7547f3..25ffbbed1 100644 --- a/reboot/dashboard/web/src/main.tsx +++ b/reboot/dashboard/web/src/main.tsx @@ -6,6 +6,7 @@ import { useOrderedMap } from "@reboot-dev/reboot-std-api/collections/ordered_ma import { RebootClientProvider } from "@reboot-dev/reboot-react"; import { Presence } from "@reboot-dev/reboot-std-react/presence"; import { + type CSSProperties, type FC, Fragment, StrictMode, @@ -38,6 +39,19 @@ import { PRESENCE_ID, } from "./constants"; import type * as api_pb from "../../../../rbt/v1alpha1/api/api_pb"; +import type * as dashboard_pb from "../../../../rbt/dashboard/v1/dashboard_pb"; +import type { Features, Printed, Span, StepLinks } from "./behaviors"; +import { + directoryOfFeature, + hueKeyOfSpan, + huesOfSpans, + linkOfCodeSpan, + linkOfMethod, + printBuiltInSyntax, + scenariosOfFeature, + sortedFeatures, + stepLinks, +} from "./behaviors"; import type { APIs, LinkedDataType, @@ -64,6 +78,8 @@ import { timeAgo, } from "./changelog"; import { DashboardGetResponse_NeedsGenerateReason as NeedsGenerateReason } from "../../../../rbt/dashboard/v1/dashboard_pb"; +import type * as feature_pb from "../../../../rbt/v1alpha1/bdd/feature_pb"; +import type * as grammar_pb from "../../../../rbt/v1alpha1/bdd/grammar_pb"; import { joinStateTypes } from "./callgraph"; import { drawnCallCount, GraphPage } from "./graph"; @@ -98,6 +114,18 @@ const DEFINITIONS: Record = { "A type the developer wrote that Reboot does not persist: what a " + "method takes, returns or raises, and anything those contain. It " + "exists while a call is in flight.", + feature: + "One .feature file: scenarios written in Gherkin that describe " + + "how the application behaves, and run as tests.", + rule: + "One business rule of the feature, illustrated by the " + + "scenarios grouped under it.", + scenario: + "One example of how the application behaves. Its steps run in " + + "order as one test.", + background: + "The steps every scenario in this group begins with, run before " + + "the scenario's own.", }; // The gap between a pill and its definition. It must equal the `8px` @@ -198,11 +226,12 @@ const Description: FC<{ className: string; text: string }> = ({ const isStandardLibrary = (packageName: string): boolean => packageName.startsWith("rbt."); -// Each page indexes the same API: `changelog` is its history, `state` -// is the state types it declares, `data` is the types those declare -// in turn, and `graph` is the calls the state types' implementations -// make to each other. -const PAGES = ["changelog", "data", "state", "graph"] as const; +// Each page indexes the same application: `changelog` is its history, +// `state` is the state types its API declares, `data` is the types +// those declare in turn, `behaviors` is the scenarios its `.feature` +// files describe, and `graph` is the calls the state types' +// implementations make to each other. +const PAGES = ["changelog", "data", "state", "behaviors", "graph"] as const; type Page = typeof PAGES[number]; @@ -210,6 +239,7 @@ const PAGE_NAMES: Record = { changelog: "Changelog", data: "Data Types", state: "State Types", + behaviors: "Behaviors", graph: "Call Graph", }; @@ -746,6 +776,465 @@ const LinkedDataTypeCard: FC<{ ); +// A custom step, one the application defines itself, which the +// grammar cannot parse: its text with the spans its author wrote in +// `backticks` as code, and a span naming a state type or a method +// linking to it on the state page. +const CustomStep: FC<{ text: string; links: StepLinks }> = ({ + text, + links, +}) => { + const parts = text.split("`"); + return ( + <> + {parts.map((part, index) => { + // `split` alternates text and code, so odd indexes are code, + // except a last part at an odd index, whose backtick was + // never closed. + const unclosed = index === parts.length - 1 && parts.length % 2 === 0; + if (index % 2 === 1 && !unclosed) { + const link = linkOfCodeSpan(part, text, links); + return link === undefined ? ( + {part} + ) : ( + + {part} + + ); + } + return {unclosed ? "`" + part : part}; + })} + + ); +}; + +const GherkinTable: FC<{ table: feature_pb.Table }> = ({ table }) => ( + + + {table.rows.map((row, index) => ( + + {row.cells.map((cell, cellIndex) => ( + + ))} + + ))} + +
{cell}
+); + +// How a scenario shows its saved values and state ids: the hue +// each is set in, keyed the way `hueKeyOfSpan` keys them, and which +// key the spans are lit up for, which a hover changes. +interface Related { + hues: Map; + key: string | null; + onRelate: (key: string | null) => void; +} + +// One span of a built-in step printed from its syntax tree, styled +// by its role: +// a state type or method links to the state page, and a save, a +// recall, or a state id is set in its own hue and lights up every +// other span about the same saved value or state. +const SpanText: FC<{ + span: Span; + stateType: string | undefined; + links: StepLinks; + related: Related; +}> = ({ span, stateType, links, related }) => { + if (span.role === "text") { + return <>{span.text}; + } + const className = `span span-${span.role}`; + const link = + span.role === "state-type" + ? links.stateTypes.get(span.text) + : span.role === "method" + ? linkOfMethod(span.text, stateType, links) + : undefined; + if (link !== undefined) { + return ( + + {span.text} + + ); + } + const key = hueKeyOfSpan(span); + if (key !== undefined) { + return ( + related.onRelate(key)} + onPointerLeave={() => related.onRelate(null)} + > + {span.text} + + ); + } + return {span.text}; +}; + +// A clause list up to this long stays on the step's line; a longer +// one puts each clause on a line of its own. +const CLAUSES_INLINE = 2; + +const Spans: FC<{ + spans: Span[]; + stateType: string | undefined; + links: StepLinks; + related: Related; +}> = ({ spans, stateType, links, related }) => ( + <> + {spans.map((span, index) => ( + + ))} + +); + +// A built-in step, printed from its syntax tree. The state type the +// step names is what says which state type's method a method name +// means. +const BuiltInStep: FC<{ + syntax: grammar_pb.BuiltInSyntax; + links: StepLinks; + related: Related; +}> = ({ syntax, links, related }) => { + const printed: Printed = printBuiltInSyntax(syntax); + const stateType = printed.head.find( + (span) => span.role === "state-type" + )?.text; + const spans = (spans: Span[]) => ( + + ); + if (printed.clauses.length <= CLAUSES_INLINE) { + return ( + <> + {spans(printed.head)} + {printed.clauses.map((clause, index) => ( + + {index > 0 && " and "} + {spans(clause)} + + ))} + {spans(printed.tail)} + + ); + } + return ( + <> + {spans(printed.head)} + {printed.clauses.map((clause, index) => ( +
+ {spans(clause)} + {index === printed.clauses.length - 1 && spans(printed.tail)} +
+ ))} + + ); +}; + +// `And` and `But` continue the step before them, so their keyword +// is set lighter, and a background's steps are set lighter still +// when shown as part of the scenario they run before. +const StepRow: FC<{ + step: feature_pb.Step; + links: StepLinks; + related: Related; + background?: boolean; +}> = ({ step, links, related, background }) => { + const continuation = step.keyword === "And" || step.keyword === "But"; + return ( +
+ + {step.keyword} + +
+ {step.builtIn !== undefined ? ( + + ) : ( + + )} + {step.docString !== undefined && ( +
+            {step.docString}
+          
+ )} + {step.table !== undefined && } +
+
+ ); +}; + +// One row of a feature's or a rule's scenario list: a scenario, or +// the background the list's scenarios share. Closed, it is one line; +// open, its steps. +const ScenarioRow: FC<{ + keyword: string; + // Absent for a bare heading naming nothing. + name?: string; + description: string; + tags: string[]; + // The backgrounds whose steps run before this scenario's own: + // the feature's, then its rule's. Shown dimmed above the steps, + // so an open scenario reads whole. + backgrounds: feature_pb.Background[]; + steps: feature_pb.Step[]; + examples: feature_pb.Examples[]; + meaning: string; + links: StepLinks; +}> = ({ + keyword, + name, + description, + tags, + backgrounds, + steps, + examples, + meaning, + links, +}) => { + const [expanded, setExpanded] = useState(false); + const [relatedKey, setRelatedKey] = useState(null); + const hues = useMemo( + () => + huesOfSpans([ + ...backgrounds.flatMap((background) => background.steps), + ...steps, + ]), + [backgrounds, steps] + ); + const related: Related = { + hues, + key: relatedKey, + onRelate: setRelatedKey, + }; + return ( +
+
setExpanded(!expanded)} + role="button" + aria-expanded={expanded} + > + {expanded ? "â–¾" : "â–¸"} + + {name} + {tags.length > 0 && ( + + {tags.map((tag) => ( + + {tag} + + ))} + + )} +
+ {/* Rendered while the row is closed too: opening is a CSS + transition on this element, not a mount. */} +
+
+ {description !== undefined && ( + + )} +
+ {backgrounds.flatMap((background, backgroundIndex) => + background.steps.map((step, index) => ( + + )) + )} + {steps.map((step, index) => ( + + ))} +
+ {examples.map((example, index) => ( +
+
+ {example.keyword.toLowerCase()} + {example.name !== undefined && ` · ${example.name}`} +
+ +
+ ))} +
+
+
+ ); +}; + +const BackgroundRow: FC<{ + background: feature_pb.Background; + links: StepLinks; +}> = ({ background, links }) => ( + +); + +// A list's own background is listed as a row of its own and folded, +// dimmed, into each of its scenarios along with any background +// inherited from the feature. +const ScenarioRows: FC<{ + inherited: feature_pb.Background[]; + background?: feature_pb.Background; + scenarios: feature_pb.Scenario[]; + links: StepLinks; +}> = ({ inherited, background, scenarios, links }) => ( +
+ {background !== undefined && ( + + )} + {scenarios.map((scenario) => ( + + ))} +
+); + +const RuleSection: FC<{ + rule: feature_pb.Rule; + inherited: feature_pb.Background[]; + links: StepLinks; +}> = ({ rule, inherited, links }) => ( +
+
+ +

{rule.name}

+ + {countWithNoun(rule.scenarios.length, "scenario")} + +
+ {rule.description !== undefined && ( + + )} + +
+); + +const FeatureCard: FC<{ + filename: string; + feature: feature_pb.Feature; + links: StepLinks; +}> = ({ filename, feature, links }) => { + const scenarios = scenariosOfFeature(feature); + return ( +
+
+ +
+
+
+

{feature.name ?? filename}

+ + + {countWithNoun(scenarios.length, "scenario")} + {feature.rules.length > 0 && + ` · ${countWithNoun(feature.rules.length, "rule")}`} + +
+
+
{filename}
+ {feature.error !== undefined ? ( +
{feature.error}
+ ) : ( + <> + {feature.description !== undefined && ( + + )} + {(feature.background !== undefined || + feature.scenarios.length > 0) && ( + + )} + {feature.rules.map((rule, index) => ( + + ))} + + )} +
+ ); +}; + const ChangeRow: FC<{ entry: Entry; now: Date }> = ({ entry, now }) => { const row = rowOfChange(entry.change); return ( @@ -885,7 +1374,13 @@ const Overview: FC<{ // drags: the drag already moves the panel. }, [navWidth, navPanel]); - const { id: target } = useParams(); + // The behaviors page names its sections by file path, whose + // slashes a `:id` segment cannot hold, so its route matches the + // rest of the URL as a splat instead. + const params = useParams(); + const target = + params.id ?? + (params["*"] === "" || params["*"] === undefined ? undefined : params["*"]); // The dashboard's own state: what it read of the developer's API // files. Nothing here calls the developer's application, so the @@ -937,6 +1432,28 @@ const Overview: FC<{ const linkedDataTypes = useMemo(() => linkDataTypes({ apis }), [apis]); + // What the developer's `.feature` files describe. + const features: Features = useMemo( + () => response?.features ?? {}, + [response?.features] + ); + + const featureEntries = useMemo(() => sortedFeatures(features), [features]); + + const scenarioCount = useMemo( + () => + featureEntries.reduce( + (total, entry) => total + scenariosOfFeature(entry.feature).length, + 0 + ), + [featureEntries] + ); + + // Where the backticked spans of steps link, derived from the same + // APIs the state page shows, so a link can never point at a state + // type the page does not have. + const links = useMemo(() => stepLinks(apis), [apis]); + // A referrer is either a state type or a data type, and its link // must open the page that lists it. const pageOfTypeId = useMemo(() => { @@ -950,6 +1467,8 @@ const Overview: FC<{ // The changelog is one list rather than a set of packages, and // the graph is one canvas, so the sidebar has nothing to index. + // The behaviors page's "packages" are the directories the feature + // files are in. const entries: NavEntry[] = useMemo( () => page === "changelog" || page === "graph" @@ -963,13 +1482,23 @@ const Overview: FC<{ count: countWithNoun(stateType.methods.length, "method"), })) ) + : page === "behaviors" + ? featureEntries.map(({ filename, feature }) => ({ + id: filename, + name: feature.name ?? filename, + package: directoryOfFeature(filename), + count: countWithNoun( + scenariosOfFeature(feature).length, + "scenario" + ), + })) : linkedDataTypes.map((linkedDataType) => ({ id: linkedDataType.id, name: linkedDataType.name, package: linkedDataType.package, count: countWithNoun(linkedDataType.properties.length, "property"), })), - [page, apis, linkedDataTypes] + [page, apis, featureEntries, linkedDataTypes] ); const packages = useMemo(() => groupByPackage(entries), [entries]); @@ -1001,7 +1530,12 @@ const Overview: FC<{ [graphStateTypes] ); - const eyebrow = page === "changelog" ? "history" : "application domain"; + const eyebrow = + page === "changelog" + ? "history" + : page === "behaviors" + ? "application behavior" + : "application domain"; const heading = page === "changelog" @@ -1016,6 +1550,11 @@ const Overview: FC<{ packages.length, "package" )}` + : page === "behaviors" + ? `${countWithNoun(scenarioCount, "scenario")} in ${countWithNoun( + featureEntries.length, + "feature" + )}` : `${countWithNoun( linkedDataTypes.length, "data type" @@ -1043,7 +1582,7 @@ const Overview: FC<{ element.scrollIntoView(); scrolledToTarget = true; } - }, [navigationType, page, target, apis, linkedDataTypes]); + }, [navigationType, page, target, apis, linkedDataTypes, featureEntries]); const navigate = useNavigate(); @@ -1117,6 +1656,7 @@ const Overview: FC<{ const counts: Record = { state: stateTypeCount, data: linkedDataTypes.length, + behaviors: scenarioCount, changelog: shownChangelog.length, graph: calls, }; @@ -1150,7 +1690,13 @@ const Overview: FC<{ @@ -1232,6 +1778,22 @@ const Overview: FC<{ ); }) ) + ) : page === "behaviors" ? ( + featureEntries.length === 0 ? ( +
+ No .feature files found. Write one and its + scenarios will show up here. +
+ ) : ( + featureEntries.map(({ filename, feature }) => ( + + )) + ) ) : linkedDataTypes.length === 0 ? (
No data types. The state types declare no requests, responses or @@ -1332,7 +1894,10 @@ const App: FC = () => { {PAGES.map((page) => ( None: properties = '`balance=50` and `owner.name="F"`' saves = '`balance` saved as `b`, and `owner` saved as `o`' mixed = '`balance=50` and `owner` saved as `o`' - assert re.fullmatch(_PROPERTY_CLAUSES, properties) - assert not re.fullmatch(_PROPERTY_CLAUSES, saves) - assert not re.fullmatch(_PROPERTY_CLAUSES, mixed) - assert re.fullmatch(_SAVE_CLAUSES, saves) - assert not re.fullmatch(_SAVE_CLAUSES, properties) - assert not re.fullmatch(_SAVE_CLAUSES, mixed) - assert re.fullmatch(_MIXED_CLAUSES, mixed) - assert not re.fullmatch(_MIXED_CLAUSES, properties) - assert not re.fullmatch(_MIXED_CLAUSES, saves) + assert re.fullmatch(PROPERTY_CLAUSES, properties) + assert not re.fullmatch(PROPERTY_CLAUSES, saves) + assert not re.fullmatch(PROPERTY_CLAUSES, mixed) + assert re.fullmatch(SAVE_CLAUSES, saves) + assert not re.fullmatch(SAVE_CLAUSES, properties) + assert not re.fullmatch(SAVE_CLAUSES, mixed) + assert re.fullmatch(MIXED_CLAUSES, mixed) + assert not re.fullmatch(MIXED_CLAUSES, properties) + assert not re.fullmatch(MIXED_CLAUSES, saves) predicates = '`name` containing `"a and b"` and `tags` of length `2`' - assert re.fullmatch(_ASSERT_CLAUSES, predicates) - assert re.fullmatch(_ASSERT_CLAUSES, properties) - assert not re.fullmatch(_ASSERT_CLAUSES, saves) - assert not re.fullmatch(_PROPERTY_CLAUSES, predicates) - assert not re.fullmatch(_SAVE_CLAUSES, predicates) + assert re.fullmatch(ASSERT_CLAUSES, predicates) + assert re.fullmatch(ASSERT_CLAUSES, properties) + assert not re.fullmatch(ASSERT_CLAUSES, saves) + assert not re.fullmatch(PROPERTY_CLAUSES, predicates) + assert not re.fullmatch(SAVE_CLAUSES, predicates) # Lexical near-misses still route to their kind. - assert re.fullmatch(_PROPERTY_CLAUSES, '`amount: 50`') - assert re.fullmatch(_PROPERTY_CLAUSES, '`amount = 50`') - assert re.fullmatch(_SAVE_CLAUSES, '`balance` saved to `b`') - assert re.fullmatch(_SAVE_CLAUSES, '`balance` saved as "$b"') - assert re.fullmatch(_SAVE_CLAUSES, '`balance` saved as b') + assert re.fullmatch(PROPERTY_CLAUSES, '`amount: 50`') + assert re.fullmatch(PROPERTY_CLAUSES, '`amount = 50`') + assert re.fullmatch(SAVE_CLAUSES, '`balance` saved to `b`') + assert re.fullmatch(SAVE_CLAUSES, '`balance` saved as "$b"') + assert re.fullmatch(SAVE_CLAUSES, '`balance` saved as b') def test_almost_clause_messages() -> None: diff --git a/tests/reboot/bdd/feature_tests.py b/tests/reboot/bdd/feature_tests.py new file mode 100644 index 000000000..22623dc72 --- /dev/null +++ b/tests/reboot/bdd/feature_tests.py @@ -0,0 +1,60 @@ +"""What `reboot.bdd.feature.parse` makes of a `.feature` file.""" +import unittest +from reboot.bdd.feature import parse + + +class ReadTest(unittest.TestCase): + """What `reboot.bdd.feature.parse` makes of the Gherkin shapes.""" + + def test_doc_strings_tables_and_outlines(self) -> None: + feature = parse( + 'Feature: F\n' + ' Scenario Outline: O\n' + ' Given a step with:\n' + ' """\n' + ' doc string body\n' + ' """\n' + ' And a table:\n' + ' | a | b |\n' + ' | 1 | 2 |\n' + ' And \n' + ' Examples: some\n' + ' | x |\n' + ' | 1 |\n' + ) + assert feature is not None + scenario = feature.scenarios[0] + self.assertEqual(scenario.keyword, 'Scenario Outline') + self.assertEqual(scenario.steps[0].doc_string, 'doc string body') + self.assertEqual( + [list(row.cells) for row in scenario.steps[1].table.rows], + [['a', 'b'], ['1', '2']], + ) + examples = scenario.examples[0] + self.assertEqual(examples.name, 'some') + self.assertEqual( + [list(row.cells) for row in examples.table.rows], + [['x'], ['1']], + ) + + def test_bare_headings_name_nothing(self) -> None: + feature = parse('Feature:\n Scenario:\n Given ok\n') + assert feature is not None + self.assertFalse(feature.HasField('name')) + self.assertFalse(feature.HasField('description')) + self.assertFalse(feature.scenarios[0].HasField('name')) + + def test_a_file_declaring_no_feature_is_none(self) -> None: + self.assertIsNone(parse('# only a comment\n')) + + def test_a_file_that_will_not_parse_carries_why(self) -> None: + feature = parse( + 'Feature: broken\n Scenario: s\n Given ok\n %%% what\n' + ) + assert feature is not None + self.assertIn('Parser errors', feature.error) + self.assertEqual(feature.name, '') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/reboot/bdd/grammar_tests.py b/tests/reboot/bdd/grammar_tests.py new file mode 100644 index 000000000..5a00207e8 --- /dev/null +++ b/tests/reboot/bdd/grammar_tests.py @@ -0,0 +1,198 @@ +"""What `reboot.bdd.grammar.parse` makes of each built-in step's +text: which built-in step it is, and the parts the step takes.""" +import unittest +from rbt.v1alpha1.bdd.grammar_pb2 import Assertion +from reboot.bdd.grammar import parse + + +def _assertion(assertion: Assertion) -> tuple[str, str, str]: + """An assertion as which arm it is, its path, and its value's + JSON.""" + arm = assertion.WhichOneof('assertion') + assert arm is not None + clause = getattr(assertion, arm) + value = { + 'equals': lambda: clause.value, + 'containing': lambda: clause.argument, + 'of_length': lambda: clause.length, + }[arm]() + return arm, clause.path, value.json + + +class ReadTest(unittest.TestCase): + + def test_a_call_with_assignments(self) -> None: + syntax = parse( + 'the `Bank` for "test-bank" gets a `transfer` with ' + '`from_account_id=${first_account_id}` and `amount=250.0` ' + 'spawned with its task id saved as `transfer_task_id`' + ) + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'gets') + gets = syntax.gets + self.assertEqual(gets.state.type, 'Bank') + self.assertEqual(gets.state.id, 'test-bank') + self.assertEqual(gets.method, 'transfer') + self.assertEqual( + [ + (assignment.path, assignment.value.json) + for assignment in gets.assignments + ], + [ + ('from_account_id', '${first_account_id}'), + ('amount', '250.0'), + ], + ) + self.assertEqual(gets.task_id_saved_as, 'transfer_task_id') + + syntax = parse('a `Account` for "alice" gets created via `open`') + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'gets_created_via') + self.assertEqual(len(syntax.gets_created_via.assignments), 0) + + syntax = parse( + 'the `Account` for "alice" gets a `deposit` with `amount=1`' + ) + assert syntax is not None + self.assertFalse(syntax.gets.HasField('task_id_saved_as')) + + # Either article, as English reads. + syntax = parse('the `Customer` for "c" gets an `open_account`') + assert syntax is not None + self.assertEqual(syntax.gets.method, 'open_account') + syntax = parse('the `Account` for "a" attempts an `overdraw`') + assert syntax is not None + self.assertEqual(syntax.attempts.method, 'overdraw') + + def test_predicates_and_saves(self) -> None: + syntax = parse( + '`all_customer_ids` on the `Bank` for "b" has ' + '`customer_ids` of length `2` and ' + '`customer_ids` containing `"test@reboot.dev"` and ' + '`total=3`' + ) + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'has') + self.assertEqual(syntax.has.method, 'all_customer_ids') + self.assertEqual( + [_assertion(assertion) for assertion in syntax.has.assertions], + [ + ('of_length', 'customer_ids', '2'), + ('containing', 'customer_ids', '"test@reboot.dev"'), + ('equals', 'total', '3'), + ], + ) + + syntax = parse( + '`get` on the `Account` for "a" has `owner` saved as `o`' + ) + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'has_saved_as') + self.assertEqual( + [(save.path, save.name) for save in syntax.has_saved_as.saves], + [('owner', 'o')], + ) + + def test_a_state_id_can_be_a_variable(self) -> None: + syntax = parse( + '`balance` on the `Account` for "${first_account_id}" has ' + '`amount=750.0`' + ) + assert syntax is not None + self.assertEqual(syntax.has.state.id, '${first_account_id}') + + def test_a_task_completing_recalls_its_id(self) -> None: + syntax = parse( + 'the `deposit` task with id "${deposit_task_id}" of the ' + '`Account` completes within 30 seconds' + ) + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'task_completes') + task_completes = syntax.task_completes + self.assertEqual(task_completes.method, 'deposit') + self.assertEqual(task_completes.task_id_saved_as, 'deposit_task_id') + self.assertEqual(task_completes.state_type, 'Account') + self.assertEqual(task_completes.seconds, 30.0) + + # A wait bound not of the grammar's form is not a syntax. + self.assertIsNone( + parse( + 'the `deposit` task with id "${deposit_task_id}" of the ' + '`Account` completes within 30s' + ) + ) + + def test_eventually_has(self) -> None: + syntax = parse( + '`balance` on the `Account` for "alice" eventually has ' + '`amount=1` within 2.5 seconds' + ) + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'eventually_has') + self.assertEqual(syntax.eventually_has.seconds, 2.5) + self.assertEqual(len(syntax.eventually_has.assertions), 1) + + def test_aborts(self) -> None: + syntax = parse('the attempt aborts with `OverdraftError`') + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'attempt_aborts_with') + self.assertEqual( + syntax.attempt_aborts_with.error_type, 'OverdraftError' + ) + self.assertEqual(len(syntax.attempt_aborts_with.assertions), 0) + + syntax = parse( + '`withdraw` on the `Account` for "alice" aborts with ' + '`OverdraftError` with `amount=50.50`' + ) + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'aborts_with') + self.assertEqual(syntax.aborts_with.error_type, 'OverdraftError') + self.assertEqual( + [_assertion(a) for a in syntax.aborts_with.assertions], + [('equals', 'amount', '50.50')], + ) + + def test_results(self) -> None: + syntax = parse('the result has `amount=10.0`') + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'result_has') + + syntax = parse( + 'the resulting `account_id` is saved as `alice_account_id`' + ) + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'resulting_is_saved_as') + self.assertEqual(syntax.resulting_is_saved_as.save.path, 'account_id') + self.assertEqual( + syntax.resulting_is_saved_as.save.name, 'alice_account_id' + ) + + def test_identity_and_application_steps(self) -> None: + syntax = parse('the "bank" application is up') + assert syntax is not None + self.assertEqual(syntax.application_is_up.name, 'bank') + + syntax = parse('the application is up') + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'application_is_up') + self.assertFalse(syntax.application_is_up.HasField('name')) + + syntax = parse('the authenticated user is "alice"') + assert syntax is not None + self.assertEqual(syntax.authenticated_user_is.user_id, 'alice') + + syntax = parse('the user is unauthenticated') + assert syntax is not None + self.assertEqual(syntax.WhichOneof('step'), 'user_is_unauthenticated') + + syntax = parse('the bearer token is "S3CR3T!"') + assert syntax is not None + self.assertEqual(syntax.bearer_token_is.bearer_token, 'S3CR3T!') + + def test_a_step_the_grammar_does_not_define_is_none(self) -> None: + self.assertIsNone(parse('the welcome email was sent')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/reboot/dashboard/BUILD.bazel b/tests/reboot/dashboard/BUILD.bazel index 0cf608605..10f310c77 100644 --- a/tests/reboot/dashboard/BUILD.bazel +++ b/tests/reboot/dashboard/BUILD.bazel @@ -48,6 +48,17 @@ py_test( ], ) +py_test( + name = "behaviors_watcher_tests_py", + srcs = ["behaviors_watcher_tests.py"], + main = "behaviors_watcher_tests.py", + deps = [ + "//reboot/aio:tests_py", + "//reboot/dashboard/backend:behaviors_watcher_py", + "//reboot/dashboard/backend:main_py", + ], +) + py_test( name = "code_watcher_tests_py", srcs = ["code_watcher_tests.py"], diff --git a/tests/reboot/dashboard/behaviors_watcher_tests.py b/tests/reboot/dashboard/behaviors_watcher_tests.py new file mode 100644 index 000000000..7af183fe3 --- /dev/null +++ b/tests/reboot/dashboard/behaviors_watcher_tests.py @@ -0,0 +1,220 @@ +"""Scenarios appear as the developer writes their feature files. + +The behaviors watcher reads every `.feature` file under the working +directory, so these tests run the dashboard in a temporary working +directory of their own. +""" +import asyncio +import os +import tempfile +import unittest +from pathlib import Path +from rbt.dashboard.v1.dashboard_rbt import Dashboard +from reboot.aio.tests import Reboot +from reboot.dashboard.backend.constants import ( + DASHBOARD_ID, + ENVVAR_RBT_API_DIRECTORY, +) +from reboot.dashboard.backend.main import application +from typing import Optional +from unittest.mock import patch + +BANK = '''Feature: Bank accounts + Money that is deposited can be withdrawn. + + Background: + Given the application is up + + Scenario: Depositing moves the balance + When the `Account` for "alice" gets a `deposit` with `amount=100` + Then `balance` on the `Account` for "alice" has `balance=100` + + Rule: Overdrafts are refused + @wip + Example: Withdrawing more than the balance + When the `Account` for "alice" attempts a `withdraw` with `amount=1` + Then the attempt aborts with `OverdraftError` + And the overdraft was logged +''' + + +class BehaviorsWatcherTest(unittest.IsolatedAsyncioTestCase): + + watcher: Optional[asyncio.Task] = None + + async def asyncSetUp(self) -> None: + # The workflow reads the working directory when the + # application comes up, so it has to be this test's own + # before then. + self._directory = tempfile.TemporaryDirectory() + self.directory = Path(self._directory.name) + self._working_directory = os.getcwd() + os.chdir(self.directory) + + # The API watcher's workflow requires a directory, even + # though these tests write no API files into it. + (self.directory / 'api').mkdir() + self._environment = patch.dict( + os.environ, + {ENVVAR_RBT_API_DIRECTORY: 'api'}, + ) + self._environment.start() + + self.rbt = Reboot() + await self.rbt.start() + + async def _start_dashboard(self) -> None: + """Brings the dashboard up. + + Called by each test rather than in setup, so a test can write + feature files first and start the dashboard against files + that already exist. + """ + await self.rbt.up(application(), local_envoy=True) + + async def asyncTearDown(self) -> None: + await self.rbt.stop() + self._environment.stop() + os.chdir(self._working_directory) + self._directory.cleanup() + + async def _wait_for_features(self, satisfied): + """Returns the recorded features once they satisfy, reading + again whenever they change.""" + context = self.rbt.create_external_context(name=self.id()) + + async for response in Dashboard.ref(DASHBOARD_ID + ).reactively().Get(context): + if satisfied(response.features): + return response.features + + raise AssertionError('never satisfied') + + def _write_feature_file(self, relative: str, source: str) -> None: + path = self.directory / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source) + + async def test_scenarios_appear_and_follow_changes(self) -> None: + """What a feature file declares is recorded against its path, + and a save is read again.""" + self._write_feature_file('backend/tests/bank.feature', BANK) + + await self._start_dashboard() + features = await self._wait_for_features( + lambda features: len(features) == 1 + ) + + feature = features['backend/tests/bank.feature'] + self.assertEqual(feature.name, 'Bank accounts') + self.assertEqual( + feature.description, 'Money that is deposited can be withdrawn.' + ) + self.assertEqual( + [step.text for step in feature.background.steps], + ['the application is up'], + ) + self.assertEqual(len(feature.scenarios), 1) + scenario = feature.scenarios[0] + self.assertEqual(scenario.keyword, 'Scenario') + self.assertEqual(scenario.name, 'Depositing moves the balance') + self.assertEqual( + [(step.keyword, step.text) for step in scenario.steps], + [ + ( + 'When', 'the `Account` for "alice" gets a `deposit` ' + 'with `amount=100`' + ), + ( + 'Then', '`balance` on the `Account` for "alice" has ' + '`balance=100`' + ), + ], + ) + # Each step the grammar defines is parsed into its parts. + built_in = scenario.steps[0].built_in + self.assertEqual(built_in.WhichOneof('step'), 'gets') + self.assertEqual(built_in.gets.state.type, 'Account') + self.assertEqual(built_in.gets.state.id, 'alice') + self.assertEqual(built_in.gets.method, 'deposit') + self.assertEqual(len(built_in.gets.assignments), 1) + self.assertEqual(len(feature.rules), 1) + rule = feature.rules[0] + self.assertEqual(rule.name, 'Overdrafts are refused') + self.assertEqual(len(rule.scenarios), 1) + self.assertEqual(rule.scenarios[0].keyword, 'Example') + self.assertEqual(list(rule.scenarios[0].tags), ['@wip']) + # A step the grammar does not define, such as one the + # application defines itself, has no built-in syntax. + self.assertFalse(rule.scenarios[0].steps[2].HasField('built_in')) + + # A second scenario, saved while the dashboard is watching. + self._write_feature_file( + 'backend/tests/bank.feature', + BANK + ''' + Scenario: A second withdrawal is also refused + When the `Account` for "alice" attempts a `withdraw` with `amount=2` + Then the attempt aborts with `OverdraftError` +''', + ) + + features = await self._wait_for_features( + lambda features: len(features) == 1 and + len(features['backend/tests/bank.feature'].rules) == 1 and + len(features['backend/tests/bank.feature'].rules[0].scenarios) == 2 + ) + + async def test_a_file_that_will_not_parse_says_so(self) -> None: + """Why a file could not be parsed is recorded against that + file, and a save that fixes it replaces the error with what + the file declares.""" + self._write_feature_file( + 'broken.feature', + 'Feature: broken\n Scenario: s\n Given ok\n %%% what\n', + ) + + await self._start_dashboard() + features = await self._wait_for_features( + lambda features: 'broken.feature' in features and features[ + 'broken.feature'].HasField('error') + ) + self.assertIn('Parser errors', features['broken.feature'].error) + + self._write_feature_file( + 'broken.feature', + 'Feature: fixed\n Scenario: s\n Given ok\n', + ) + + features = await self._wait_for_features( + lambda features: 'broken.feature' in features and not features[ + 'broken.feature'].HasField('error') + ) + self.assertEqual(features['broken.feature'].name, 'fixed') + + async def test_installed_packages_feature_files_are_left_out( + self, + ) -> None: + """A `.venv` or `node_modules` carries installed packages' + feature files, which are not the developer's.""" + self._write_feature_file('backend/tests/bank.feature', BANK) + self._write_feature_file( + '.venv/lib/site-packages/other/their.feature', + 'Feature: theirs\n', + ) + self._write_feature_file( + 'node_modules/other/their.feature', + 'Feature: theirs\n', + ) + + await self._start_dashboard() + features = await self._wait_for_features( + lambda features: len(features) > 0 + ) + self.assertEqual( + list(features.keys()), + ['backend/tests/bank.feature'], + ) + + +if __name__ == '__main__': + unittest.main() From 089a63392eff286ca85deb69295393d4f85fbde3 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Sat, 5 Sep 2026 16:13:39 +0000 Subject: [PATCH 28/42] Spell `reboot.bdd` variables as , the way Gherkin does A saved value was said as `${name}`, while a Scenario Outline says a column of its Examples table as ``, so a writer learned two spellings for one idea: a value that comes from somewhere else. Now both are ``. pytest-bdd substitutes a column's value before a step runs and leaves any other `` as written, so a saved value said the same way reaches the step untouched; a quoted `""` stays the literal string, as before. Because a column's `` 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 `` 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- rbt/v1alpha1/bdd/grammar.proto | 11 +- reboot/bdd/fixtures.py | 28 ++++- reboot/bdd/grammar.py | 2 +- reboot/bdd/steps.py | 86 +++++++------ reboot/dashboard/web/dashboard.css | 34 +++--- reboot/dashboard/web/src/behaviors.ts | 104 ++++++++++------ reboot/dashboard/web/src/main.tsx | 113 ++++++++++++++---- .../backend/tests/wiki_crud.feature | 8 +- .../backend/tests/wiki_ingest.feature | 10 +- .../backend/tests/wiki_ingest_test.py | 4 +- .../backend/tests/wiki_transcript.feature | 4 +- .../bank-pydantic/backend/tests/bank.feature | 24 ++-- .../chick-potle/backend/tests/food.feature | 28 ++--- .../bank/backend/tests/account.feature | 2 +- .../monorepo/bank/backend/tests/bank.feature | 14 +-- .../hello-tasks/backend/tests/hello.feature | 4 +- .../backend/tests/store.feature | 8 +- tests/reboot/bdd/accounts.feature | 20 ++-- tests/reboot/bdd/bdd_tests.py | 14 ++- tests/reboot/bdd/grammar_tests.py | 12 +- tests/reboot/bdd/pydantic/accounts.feature | 4 +- 21 files changed, 339 insertions(+), 195 deletions(-) diff --git a/rbt/v1alpha1/bdd/grammar.proto b/rbt/v1alpha1/bdd/grammar.proto index cd6768d13..9cfe88863 100644 --- a/rbt/v1alpha1/bdd/grammar.proto +++ b/rbt/v1alpha1/bdd/grammar.proto @@ -7,9 +7,10 @@ package rbt.v1alpha1.bdd; // A value as a step writes it: JSON (JSON5, so keys need no quotes), // parsed against the property it is set on or asserted against when // the scenario runs, since what `1` means depends on that property's -// type. May hold a variable, `${name}`, whose saved value is spliced -// in first; a value that is only a variable is the saved value -// itself. +// type. May hold a variable, ``, whose value is spliced in +// first: a column of a Scenario Outline's Examples table, or a value +// a step before it saved; a value that is only a variable is the +// saved value itself. message Value { string json = 1; } @@ -21,7 +22,7 @@ message State { string type = 1; // The id as written, without its quotes; may be a variable, - // `${name}`, whose saved value is the id. + // ``, whose value is the id. string id = 2; } @@ -130,7 +131,7 @@ message Attempts { repeated Assignment assignments = 3; } -// 'the `deposit` task with id "${name}" of the `Account` completes +// 'the `deposit` task with id "" of the `Account` completes // within 30 seconds'. message TaskCompletes { string method = 1; diff --git a/reboot/bdd/fixtures.py b/reboot/bdd/fixtures.py index 80c43b352..851e16c14 100644 --- a/reboot/bdd/fixtures.py +++ b/reboot/bdd/fixtures.py @@ -151,10 +151,16 @@ class World: # The response of the most recent call a step made. response: Optional[Any] = None - # Values saved under a name, as JSON; later steps say '$name' to + # Values saved under a name, as JSON; later steps say '' to # use. saved: dict[str, JsonValue] = field(default_factory=dict) + # The columns of the scenario's Examples table, for a Scenario + # Outline; empty for a plain scenario. A '' naming a column + # is the table's value, substituted before any step runs, so a + # save may not use a column's name. + example_columns: frozenset[str] = frozenset() + # The error the most recent 'attempts' step's call aborted with, # or `None` if that call succeeded. aborted: Optional[Aborted] = None @@ -190,6 +196,18 @@ def context(self) -> ExternalContext: bearer_token=self.bearer_token, ) + def save(self, name: str, value: JsonValue) -> None: + """Saves the value under the name, for later steps to say + '' to use; raises for a name that is a column of the + scenario's Examples table, whose value '' already is.""" + if name in self.example_columns: + raise ValueError( + f"`{name}` is a column of the scenario's Examples table, " + f"so <{name}> in a step is the table's value, never this " + "one; save it under another name" + ) + self.saved[name] = value + def set_bearer_token(self, bearer_token: Optional[str]) -> None: """Sets the bearer token every context created from here on carries, `None` for unauthenticated, satisfying the say-who- @@ -485,6 +503,8 @@ async def call( @pytest.fixture -def world() -> World: - """The scenario's world: the mutable record its steps share.""" - return World() +def world(_pytest_bdd_example: dict[str, str]) -> World: + """The scenario's world: the mutable record its steps share. The + example is pytest-bdd's: the Examples row a Scenario Outline runs + with, and empty for a plain scenario.""" + return World(example_columns=frozenset(_pytest_bdd_example)) diff --git a/reboot/bdd/grammar.py b/reboot/bdd/grammar.py index 870d9cb3f..24c87ce08 100644 --- a/reboot/bdd/grammar.py +++ b/reboot/bdd/grammar.py @@ -141,7 +141,7 @@ ) ATTEMPTS = rf'{STATE} attempts (?:a|an) `(?P\w+)`{PROPERTIES}$' TASK_COMPLETES = ( - r'the `(?P\w+)` task with id "\$\{(?P\w+)\}" ' + r'the `(?P\w+)` task with id "<(?P\w+)>" ' r'of the `(?P[\w.]+)` completes within (?P.+)$' ) ATTEMPT_ABORTS_WITH = ( diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 05c1a104c..2ddf23d2f 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -50,7 +50,7 @@ def application() -> Application: A call runs as a task instead by saying 'gets a `method` ... spawned with its task id saved as `name`'; the task then awaits as -'the `method` task with id "${name}" of the `Account` completes +'the `method` task with id "" of the `Account` completes within 10 seconds', recording its response as the result. A task ID a response carries saves and awaits the same way. @@ -68,15 +68,17 @@ def application() -> Application: An asserting list can also say the predicates `path` containing `value` (a substring of a string, an element of a list, or a key of a map) and `path` of length `n`; the backticked argument is a JSON -value the way a property's value is, so it can recall ${name}. A Given or When 'has' instead -saves a property under a backticked name, which later steps recall -as `${name}`, in a state's ID, a user's ID, a bearer token, or a -property value (a quoted "${name}" stays the literal string): +value the way a property's value is, so it can say . A Given +or When 'has' instead saves a property under a backticked name, +which later steps say as , the way a Scenario Outline says a +column of its Examples table, in a state's ID, a user's ID, a bearer +token, or a property value (a quoted "" stays the literal +string); a save may not use a column's name: When `get_owner` on the `Account` for "frank" has `owner.name` saved as `owner_name` And the resulting `updated_balance` is saved as `balance` - And the `Account` for "${owner_name}" gets a `deposit` with + And the `Account` for "" gets a `deposit` with `amount=1` """ @@ -201,16 +203,25 @@ def _saved_value(world: World, name: str) -> JsonValue: return world.saved[name] +def _almost_variable_message(text: str) -> Optional[str]: + """The 'Almost' error for a text that is a lexical near-miss of + the variable , and `None` for a text that is not.""" + if re.fullmatch(r'\$\{\w+\}', text): + return f"Almost: say a saved value as <{text[2:-1]}>, not {text}" + if re.fullmatch(r'\$\w+', text): + return f"Almost: say a saved value as <{text[1:]}>, not {text}" + return None + + def _maybe_saved(world: World, text: str) -> str: """The saved value the text names when it is of the form - '${name}', which must be a string, otherwise the text itself.""" - if re.fullmatch(r'\$\w+', text): - raise ValueError( - f"Almost: recall a save as ${{{text[1:]}}}, not {text}" - ) - if not re.fullmatch(r'\$\{\w+\}', text): + '', which must be a string, otherwise the text itself.""" + almost = _almost_variable_message(text) + if almost is not None: + raise ValueError(almost) + if not re.fullmatch(r'<\w+>', text): return text - value = _saved_value(world, text[2:-1]) + value = _saved_value(world, text[1:-1]) if not isinstance(value, str): raise ValueError( f"Expecting the value saved as `{text[2:-1]}` to be a " @@ -267,14 +278,13 @@ def _almost_length_message(clause: str) -> str: def _parsed_value(world: World, label: str, text: str) -> JsonValue: - """The JSON value the text says, a '${name}' recalling a save; a - lexical near-miss raises the fix.""" - if re.fullmatch(r'\$\w+', text): - raise ValueError( - f"Almost: recall a save as ${{{text[1:]}}}, not {text}" - ) - if re.fullmatch(r'\$\{\w+\}', text): - return _saved_value(world, text[2:-1]) + """The JSON value the text says, a '' being the saved value + going by that name; a lexical near-miss raises the fix.""" + almost = _almost_variable_message(text) + if almost is not None: + raise ValueError(almost) + if re.fullmatch(r'<\w+>', text): + return _saved_value(world, text[1:-1]) try: return json5.loads(text) except ValueError as error: @@ -318,6 +328,12 @@ def _almost_save_message(clause: str) -> str: "Almost: drop the '$' and say the name in backticks, " f"e.g. saved as `name`: {clause}" ) + if re.search(r'\bsaved\s+as\s+"?<\w+>"?$', clause): + return ( + "Almost: the name goes in backticks without angle brackets, " + f"e.g. saved as `name`; is how a later step says it: " + f"{clause}" + ) if re.search(r'\bsaved\s+as\s+"\w+"$', clause): return ( "Almost: the name goes in backticks, not quotes, e.g. " @@ -340,10 +356,9 @@ def _parse_assignments( ) -> list[Assignment]: """Parses a call's 'with' list, e.g. '`amount=50` and `reason="promo"`', into `Assignment`s; a property value of the - form '${name}' becomes the saved value going by that name. The - step - patterns admit lexical near-misses of a clause, so each clause is - confirmed strict here, raising the fix.""" + form '' becomes the saved value going by that name. The + step patterns admit lexical near-misses of a clause, so each + clause is confirmed strict here, raising the fix.""" assignments: list[Assignment] = [] if clauses is None: return assignments @@ -836,7 +851,7 @@ async def _gets( method=method, assignments=_parse_assignments(world, clauses), ) - world.saved[task] = _json_object(handle.task_id) + world.save(task, _json_object(handle.task_id)) return if world.is_reader(state_type=state_type, method=method): raise ValueError( @@ -1057,7 +1072,7 @@ async def _has_saved_as( response = await _read(world, method, state_type, state_id) response_json = _json_object(response) for name, path in _parse_saves(clauses).items(): - world.saved[name] = _resolve_json_property(response_json, path) + world.save(name, _resolve_json_property(response_json, path)) @then(parsers.re(ABORTS_WITH)) @@ -1112,8 +1127,11 @@ def _the_resulting_property_is_saved_as( "Expected a preceding step to have made a call that returned " "a response, but there is none" ) - world.saved[name] = _resolve_json_property( - _json_object(world.response), PropertyPath.create(property_name) + world.save( + name, + _resolve_json_property( + _json_object(world.response), PropertyPath.create(property_name) + ), ) @@ -1124,14 +1142,8 @@ def _the_resulting_property_is_saved_as( # step's tail never matches one of these. -@when( - parsers. - re(r'the `\w+` task with id "\$\{\w+\}" of the `[\w.]+` completes$') -) -@then( - parsers. - re(r'the `\w+` task with id "\$\{\w+\}" of the `[\w.]+` completes$') -) +@when(parsers.re(r'the `\w+` task with id "<\w+>" of the `[\w.]+` completes$')) +@then(parsers.re(r'the `\w+` task with id "<\w+>" of the `[\w.]+` completes$')) def _almost_completes_needs_within() -> None: raise ValueError( "Almost: say how long to wait for the task, e.g. within 10 " diff --git a/reboot/dashboard/web/dashboard.css b/reboot/dashboard/web/dashboard.css index dd86b769e..8088cc93a 100644 --- a/reboot/dashboard/web/dashboard.css +++ b/reboot/dashboard/web/dashboard.css @@ -1772,9 +1772,10 @@ header h1 { padding-left: 1.5em; } -/* What the grammar read a span of a step as. A span is set as code +/* What the grammar read a span of a step as, and an Examples table's + header cells, which are variables too. A span is set as code whatever its role; the role picks its colour. */ -.step code.span { +.scenario-detail code.span { font-family: ui-monospace, Menlo, monospace; font-size: 0.9em; padding: 0 4px; @@ -1784,18 +1785,18 @@ header h1 { } /* Blue: what the step names in the API, which links there. */ -.step code.span-state-type, -.step code.span-method { +.scenario-detail code.span-state-type, +.scenario-detail code.span-method { color: hsl(211 72% 32%); } /* Violet: a property being set, read, or saved. */ -.step code.span-property-path { +.scenario-detail code.span-property-path { color: hsl(275 50% 40%); } /* Teal: a literal value. */ -.step code.span-value { +.scenario-detail code.span-value { color: hsl(166 55% 27%); } @@ -1805,29 +1806,29 @@ header h1 { thing across a scenario and the next reads as another; hovering any lights up every span about the same one. The fallback hue is for a span the page gave none. */ -.step code.span-saved-name, -.step code.span-variable, -.step code.span-state-id { +.scenario-detail code.span-saved-name, +.scenario-detail code.span-variable, +.scenario-detail code.span-state-id { color: hsl(var(--hue, 28) 70% 32%); background: hsl(var(--hue, 28) 85% 55% / 0.14); border-color: hsl(var(--hue, 28) 70% 50% / 0.4); cursor: default; } -.step code.is-related { +.scenario-detail code.is-related { background: hsl(var(--hue, 28) 85% 55% / 0.45); border-color: hsl(var(--hue, 28) 70% 40%); } /* Magenta: an error type. */ -.step code.span-error-type { +.scenario-detail code.span-error-type { color: hsl(318 55% 38%); } /* Muted: who is calling, which application, and how long to wait. */ -.step code.span-user, -.step code.span-application, -.step code.span-duration { +.scenario-detail code.span-user, +.scenario-detail code.span-application, +.scenario-detail code.span-duration { color: hsl(var(--muted-foreground)); } @@ -1849,6 +1850,11 @@ header h1 { background: hsl(var(--surface-sunken)); } +/* The cells under a column whose variable is hovered. */ +.gherkin-table td.is-related { + background: hsl(var(--hue, 28) 85% 55% / 0.25); +} + .rule { margin-top: 26px; display: flex; diff --git a/reboot/dashboard/web/src/behaviors.ts b/reboot/dashboard/web/src/behaviors.ts index ebf50cb96..993972527 100644 --- a/reboot/dashboard/web/src/behaviors.ts +++ b/reboot/dashboard/web/src/behaviors.ts @@ -155,20 +155,22 @@ export interface Printed { const text = (words: string): Span => ({ text: words, role: "text" }); -// A variable in a step's text, `${name}`, whose saved value is spliced -// in when the scenario runs. -const VARIABLE = /\$\{\w+\}/g; +// A variable in a step's text, ``: a column of a Scenario +// Outline's Examples table, or a value a step before it saved, spliced +// in when the scenario runs. The same shape pytest-bdd substitutes. +const VARIABLE = /<[^<>]+>/g; -// Text that may hold variables, as spans: each variable as one, and -// the text between as spans of `role`. -const spansOfText = (words: string, role: Role): Span[] => { +// Text that may hold variables, as spans: each variable as one, its +// name without the angle brackets, since the page sets a variable +// apart by its role; and the text between as spans of `role`. +export const spansOfText = (words: string, role: Role): Span[] => { const spans: Span[] = []; let at = 0; for (const match of words.matchAll(VARIABLE)) { if (match.index > at) { spans.push({ text: words.slice(at, match.index), role }); } - spans.push({ text: match[0], role: "variable" }); + spans.push({ text: match[0].slice(1, -1), role: "variable" }); at = match.index + match[0].length; } if (at < words.length) { @@ -360,7 +362,7 @@ export const printBuiltInSyntax = ( text("the "), { text: step.value.method, role: "method" }, text(" task with id "), - { text: "${" + step.value.taskIdSavedAs + "}", role: "variable" }, + { text: step.value.taskIdSavedAs, role: "variable" }, text(" of the "), { text: step.value.stateType, role: "state-type" }, text(" completes within "), @@ -464,50 +466,74 @@ export const spansOfPrinted = (printed: Printed): Span[] => [ ...printed.tail, ]; -// The spans a scenario sets in a hue of their own: each saved value, -// where it is saved and where it is recalled, and each state id, +// The key a variable's hue is under: the same for the column of an +// Examples table, a save, and every `` saying either, since a +// save may not use a column's name. +export const hueKeyOfVariable = (name: string): string => `variable:${name}`; + +// The spans a scenario sets in a hue of their own: each variable, +// where it is saved and wherever it is said, and each state id, // wherever it is named. What the hue is keyed by says which of the -// two a span is, since a state id and a saved name may be spelled -// the same. +// two a span is, since a state id and a variable may be spelled the +// same. export const hueKeyOfSpan = (span: Span): string | undefined => span.role === "state-id" ? `state:${span.text}` - : span.role === "variable" - ? `saved:${span.text.slice(2, -1)}` - : span.role === "saved-name" - ? `saved:${span.text}` + : span.role === "variable" || span.role === "saved-name" + ? hueKeyOfVariable(span.text) : undefined; -// Hues far enough apart to tell one saved value from the next, and -// one state id from the next; the two palettes share no hue, and -// both keep clear of the hues the other roles are set in. -const SAVED_HUES = [28, 350, 110, 190, 300, 55]; +// Hues far enough apart to tell one variable from the next, and one +// state id from the next; the two palettes share no hue, and both +// keep clear of the hues the other roles are set in. +const VARIABLE_HUES = [28, 350, 110, 190, 300, 55]; const STATE_ID_HUES = [150, 245, 80, 325, 5, 215]; -// The hue each saved value and each state id of a scenario is set -// in, keyed the way `hueKeyOfSpan` keys them and assigned in the -// order the keys first appear across the steps, each kind from its -// own palette. -export const huesOfSpans = (steps: feature_pb.Step[]): Map => { +// The spans of a step as the page prints it: from its syntax tree for +// a built-in step, and from its text, with only the variables picked +// out, for a custom step. +export const spansOfStep = (step: feature_pb.Step): Span[] => + step.builtIn === undefined + ? spansOfText(step.text, "text") + : spansOfPrinted(printBuiltInSyntax(step.builtIn)); + +// The hue each variable and each state id of a scenario is set in, +// keyed the way `hueKeyOfSpan` keys them: the Examples table's +// columns first, left to right, then the rest in the order they first +// appear across the steps, each kind from its own palette. +export const huesOfScenario = ( + columns: string[], + steps: feature_pb.Step[] +): Map => { const hues = new Map(); - const counts = { saved: 0, state: 0 }; - for (const step of steps) { - if (step.builtIn === undefined) { - continue; + const counts = { variable: 0, state: 0 }; + const assign = (key: string) => { + if (hues.has(key)) { + return; } - for (const span of spansOfPrinted(printBuiltInSyntax(step.builtIn))) { + if (key.startsWith("state:")) { + hues.set(key, STATE_ID_HUES[counts.state % STATE_ID_HUES.length]); + counts.state += 1; + } else { + hues.set(key, VARIABLE_HUES[counts.variable % VARIABLE_HUES.length]); + counts.variable += 1; + } + }; + for (const column of columns) { + assign(hueKeyOfVariable(column)); + } + for (const step of steps) { + for (const span of spansOfStep(step)) { const key = hueKeyOfSpan(span); - if (key === undefined || hues.has(key)) { - continue; - } - if (key.startsWith("state:")) { - hues.set(key, STATE_ID_HUES[counts.state % STATE_ID_HUES.length]); - counts.state += 1; - } else { - hues.set(key, SAVED_HUES[counts.saved % SAVED_HUES.length]); - counts.saved += 1; + if (key !== undefined) { + assign(key); } } } return hues; }; + +// The columns of a scenario's Examples tables: each table's header +// row, in order. +export const columnsOfExamples = (examples: feature_pb.Examples[]): string[] => + examples.flatMap((example) => example.table?.rows[0]?.cells ?? []); diff --git a/reboot/dashboard/web/src/main.tsx b/reboot/dashboard/web/src/main.tsx index 25ffbbed1..177476836 100644 --- a/reboot/dashboard/web/src/main.tsx +++ b/reboot/dashboard/web/src/main.tsx @@ -42,14 +42,17 @@ import type * as api_pb from "../../../../rbt/v1alpha1/api/api_pb"; import type * as dashboard_pb from "../../../../rbt/dashboard/v1/dashboard_pb"; import type { Features, Printed, Span, StepLinks } from "./behaviors"; import { + columnsOfExamples, directoryOfFeature, hueKeyOfSpan, - huesOfSpans, + hueKeyOfVariable, + huesOfScenario, linkOfCodeSpan, linkOfMethod, printBuiltInSyntax, scenariosOfFeature, sortedFeatures, + spansOfText, stepLinks, } from "./behaviors"; import type { @@ -778,12 +781,13 @@ const LinkedDataTypeCard: FC<{ // A custom step, one the application defines itself, which the // grammar cannot parse: its text with the spans its author wrote in -// `backticks` as code, and a span naming a state type or a method -// linking to it on the state page. -const CustomStep: FC<{ text: string; links: StepLinks }> = ({ - text, - links, -}) => { +// `backticks` as code, a span naming a state type or a method linking +// to it on the state page, and each `` set in its hue. +const CustomStep: FC<{ + text: string; + links: StepLinks; + related: Related; +}> = ({ text, links, related }) => { const parts = text.split("`"); return ( <> @@ -806,25 +810,82 @@ const CustomStep: FC<{ text: string; links: StepLinks }> = ({ ); } - return {unclosed ? "`" + part : part}; + return ( + + {spansOfText(unclosed ? "`" + part : part, "text").map( + (span, spanIndex) => ( + + ) + )} + + ); })} ); }; -const GherkinTable: FC<{ table: feature_pb.Table }> = ({ table }) => ( - - - {table.rows.map((row, index) => ( - - {row.cells.map((cell, cellIndex) => ( - - ))} - - ))} - -
{cell}
-); +// A step's data table, or an examples table with its header row +// first. An examples table's columns are variables: each header cell +// is set in the column's hue and lights up with every `` saying +// it, and the cells under it light up too. +const GherkinTable: FC<{ + table: feature_pb.Table; + examples?: Related; +}> = ({ table, examples }) => { + const columns = table.rows[0]?.cells ?? []; + return ( + + + {table.rows.map((row, index) => ( + + {row.cells.map((cell, cellIndex) => { + if (examples === undefined) { + return ; + } + const key = hueKeyOfVariable(columns[cellIndex] ?? ""); + const isRelated = examples.key === key; + if (index === 0) { + return ( + + ); + } + return ( + + ); + })} + + ))} + +
{cell} + examples.onRelate(key)} + onPointerLeave={() => examples.onRelate(null)} + > + {cell} + + + {cell} +
+ ); +}; // How a scenario shows its saved values and state ids: the hue // each is set in, keyed the way `hueKeyOfSpan` keys them, and which @@ -972,7 +1033,7 @@ const StepRow: FC<{ {step.builtIn !== undefined ? ( ) : ( - + )} {step.docString !== undefined && (
@@ -1017,11 +1078,11 @@ const ScenarioRow: FC<{
   const [relatedKey, setRelatedKey] = useState(null);
   const hues = useMemo(
     () =>
-      huesOfSpans([
+      huesOfScenario(columnsOfExamples(examples), [
         ...backgrounds.flatMap((background) => background.steps),
         ...steps,
       ]),
-    [backgrounds, steps]
+    [backgrounds, steps, examples]
   );
   const related: Related = {
     hues,
@@ -1083,7 +1144,9 @@ const ScenarioRow: FC<{
                 {example.keyword.toLowerCase()}
                 {example.name !== undefined && ` · ${example.name}`}
               
- + {example.table !== undefined && ( + + )} ))} diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature b/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature index e334e2600..22574bc68 100644 --- a/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature +++ b/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature @@ -7,14 +7,14 @@ Feature: Wiki, page, and transcript CRUD Scenario: A created wiki appears in the user's list When the `User` for "alice" gets a `create_wiki` with `name="my notes"` and `description="my personal notes"` And the resulting `wiki_id` is saved as `wiki_id` - Then `list_wikis` on the `User` for "alice" has `wikis` of length `1` and `wikis[0].wiki_id=${wiki_id}` and `wikis[0].name="my notes"` and `wikis[0].description="my personal notes"` + Then `list_wikis` on the `User` for "alice" has `wikis` of length `1` and `wikis[0].wiki_id=` and `wikis[0].name="my notes"` and `wikis[0].description="my personal notes"` Scenario: A fresh wiki updates its markdown body Given the `User` for "alice" gets a `create_wiki` with `name="my notes"` and `description="my personal notes"` And the resulting `wiki_id` is saved as `wiki_id` - Then `get` on the `Wiki` for "${wiki_id}" has `name="my notes"` and `description="my personal notes"` and `content=""` - When the `Wiki` for "${wiki_id}" gets a `update` with `content="# Hello\n"` - Then `get` on the `Wiki` for "${wiki_id}" has `content="# Hello\n"` + Then `get` on the `Wiki` for "" has `name="my notes"` and `description="my personal notes"` and `content=""` + When the `Wiki` for "" gets a `update` with `content="# Hello\n"` + Then `get` on the `Wiki` for "" has `content="# Hello\n"` Scenario: Pages round-trip their title and body Given a `Page` for "my-page" gets created via `create` with `title="My Page"` and `content="Initial body."` and `owner_id="alice"` diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature b/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature index d3be57e77..38b53007c 100644 --- a/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature +++ b/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature @@ -7,10 +7,10 @@ Feature: Ingesting transcripts through the librarian Scenario: Adding a transcript wakes the librarian Given the `User` for "alice" gets a `create_wiki` with `name="notes"` and `description="knowledge base"` And the resulting `wiki_id` is saved as `wiki_id` - When the `Wiki` for "${wiki_id}" gets a `add_transcript` with `messages=[{role: "user", content: "Tell me about X."}, {role: "assistant", content: "X is a thing that does Y."}]` - Then `get` on the `Wiki` for "${wiki_id}" eventually has `content` containing `"[Test Page](Page:"` within 30 seconds - # The scripted librarian saves ${page_id} the moment its + When the `Wiki` for "" gets a `add_transcript` with `messages=[{role: "user", content: "Tell me about X."}, {role: "assistant", content: "X is a thing that does Y."}]` + Then `get` on the `Wiki` for "" eventually has `content` containing `"[Test Page](Page:"` within 30 seconds + # The scripted librarian saves the moment its # `create_page` tool returns, which is before the wiki's content # updates, so once the line above passes the save exists. - And `get` on the `Wiki` for "${wiki_id}" has `content` containing `${page_id}` - And `get` on the `Page` for "${page_id}" has `title="Test Page"` and `content="Distilled transcript content."` + And `get` on the `Wiki` for "" has `content` containing `` + And `get` on the `Page` for "" has `title="Test Page"` and `content="Distilled transcript content."` diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_ingest_test.py b/reboot/examples/agent-wiki/backend/tests/wiki_ingest_test.py index a95975630..dbf7ebffb 100644 --- a/reboot/examples/agent-wiki/backend/tests/wiki_ingest_test.py +++ b/reboot/examples/agent-wiki/backend/tests/wiki_ingest_test.py @@ -63,8 +63,8 @@ async def step( if part.tool_name == "create_page": self.page_id = str(part.content) # Save the ID so the scenario can recall it - # as ${page_id}. - self.world.saved['page_id'] = self.page_id + # as . + self.world.save('page_id', self.page_id) if "get_wiki" not in returned_tools: return ModelResponse( diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature b/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature index 97dafcd2d..05124ec45 100644 --- a/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature +++ b/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature @@ -7,6 +7,6 @@ Feature: Adding transcripts to a wiki Scenario: Adding a transcript creates it Given the `User` for "alice" gets a `create_wiki` with `name="notes"` and `description=""` And the resulting `wiki_id` is saved as `wiki_id` - When the `Wiki` for "${wiki_id}" gets a `add_transcript` with `messages=[{role: "user", content: "Hi."}, {role: "assistant", content: "Hello!"}]` + When the `Wiki` for "" gets a `add_transcript` with `messages=[{role: "user", content: "Hi."}, {role: "assistant", content: "Hello!"}]` And the resulting `transcript_id` is saved as `transcript_id` - Then `get` on the `Transcript` for "${transcript_id}" has `messages` of length `2` and `messages[0].content="Hi."` and `messages[1].content="Hello!"` + Then `get` on the `Transcript` for "" has `messages` of length `2` and `messages[0].content="Hi."` and `messages[1].content="Hello!"` diff --git a/reboot/examples/bank-pydantic/backend/tests/bank.feature b/reboot/examples/bank-pydantic/backend/tests/bank.feature index ee4c317b7..a5a12fb9a 100644 --- a/reboot/examples/bank-pydantic/backend/tests/bank.feature +++ b/reboot/examples/bank-pydantic/backend/tests/bank.feature @@ -12,21 +12,27 @@ Feature: Bank And the `Bank` for "test-bank" gets a `sign_up` with `customer_id="test2@reboot.dev"` And the `Customer` for "test2@reboot.dev" gets a `open_account` with `initial_deposit=0.0` And the resulting `account_id` is saved as `second_account_id` - And the `Bank` for "test-bank" gets a `transfer` with `from_account_id=${first_account_id}` and `to_account_id=${second_account_id}` and `amount=250.0` - Then `balance` on the `Account` for "${first_account_id}" has `amount=750.0` - And `balance` on the `Account` for "${second_account_id}" has `amount=250.0` + And the `Bank` for "test-bank" gets a `transfer` with `from_account_id=` and `to_account_id=` and `amount=250.0` + Then `balance` on the `Account` for "" has `amount=750.0` + And `balance` on the `Account` for "" has `amount=250.0` And `all_customer_ids` on the `Bank` for "test-bank" has `customer_ids` of length `2` and `customer_ids` containing `"test@reboot.dev"` and `customer_ids` containing `"test2@reboot.dev"` And `account_balances` on the `Bank` for "test-bank" has `balances` of length `2` and `balances[0].customer_id="test@reboot.dev"` and `balances[0].accounts` of length `1` and `balances[0].accounts[0].balance=750.0` and `balances[1].customer_id="test2@reboot.dev"` and `balances[1].accounts` of length `1` and `balances[1].accounts[0].balance=250.0` - Scenario: Overdrafts are refused - Given an `Account` for "overdraft-account" gets created via `open` - When the `Account` for "overdraft-account" attempts a `withdraw` with `amount=50.50` - Then the attempt aborts with `OverdraftError` with `amount=50.50` + Scenario Outline: Overdrafts are refused + Given an `Account` for "" gets created via `open` + When the `Account` for "" gets a `deposit` with `amount=` + And the `Account` for "" attempts a `withdraw` with `amount=` + Then the attempt aborts with `OverdraftError` with `amount=` + + Examples: + | account | deposit | withdrawal | shortfall | + | empty-account | 0.0 | 50.50 | 50.50 | + | funded-account | 20.0 | 50.50 | 30.50 | Scenario: Spawned deposits and reads complete Given an `Account` for "spawning-account" gets created via `open` When the `Account` for "spawning-account" gets a `deposit` with `amount=10.0` spawned with its task id saved as `deposit_task_id` - Then the `deposit` task with id "${deposit_task_id}" of the `Account` completes within 30 seconds + Then the `deposit` task with id "" of the `Account` completes within 30 seconds When the `Account` for "spawning-account" gets a `balance` spawned with its task id saved as `balance_task_id` - Then the `balance` task with id "${balance_task_id}" of the `Account` completes within 30 seconds + Then the `balance` task with id "" of the `Account` completes within 30 seconds And the result has `amount=10.0` diff --git a/reboot/examples/chick-potle/backend/tests/food.feature b/reboot/examples/chick-potle/backend/tests/food.feature index 29b7fa6ad..ba47167d7 100644 --- a/reboot/examples/chick-potle/backend/tests/food.feature +++ b/reboot/examples/chick-potle/backend/tests/food.feature @@ -7,37 +7,37 @@ Feature: Food orders Scenario: Starting an order pre-populates the menu with an empty cart When the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` - Then `get_menu` on the `FoodOrder` for "${order_id}" has `items` of length `10` and `items[0].name="Chicken Burrito"` and `items[0].category="Burritos"` and `items[0].price_cents=1115` - And `get_cart` on the `FoodOrder` for "${order_id}" has `entries=[]` and `total_cents=0` + Then `get_menu` on the `FoodOrder` for "" has `items` of length `10` and `items[0].name="Chicken Burrito"` and `items[0].category="Burritos"` and `items[0].price_cents=1115` + And `get_cart` on the `FoodOrder` for "" has `entries=[]` and `total_cents=0` Scenario: Adding the same item twice increments its quantity Given the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` - When the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=0` and `quantity=1` - And the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=0` and `quantity=1` - And the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=1` and `quantity=1` - Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length `2` and `entries[0].item_index=0` and `entries[0].quantity=2` and `entries[1].item_index=1` and `entries[1].quantity=1` and `total_cents=3470` - When the `FoodOrder` for "${order_id}" gets a `remove_from_cart` with `item_index=0` - Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length `1` and `entries[0].item_index=1` and `total_cents=1240` + When the `FoodOrder` for "" gets a `add_to_cart` with `item_index=0` and `quantity=1` + And the `FoodOrder` for "" gets a `add_to_cart` with `item_index=0` and `quantity=1` + And the `FoodOrder` for "" gets a `add_to_cart` with `item_index=1` and `quantity=1` + Then `get_cart` on the `FoodOrder` for "" has `entries` of length `2` and `entries[0].item_index=0` and `entries[0].quantity=2` and `entries[1].item_index=1` and `entries[1].quantity=1` and `total_cents=3470` + When the `FoodOrder` for "" gets a `remove_from_cart` with `item_index=0` + Then `get_cart` on the `FoodOrder` for "" has `entries` of length `1` and `entries[0].item_index=1` and `total_cents=1240` Scenario: A quantity of zero means one Given the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` - When the `FoodOrder` for "${order_id}" gets a `add_to_cart` with `item_index=0` and `quantity=0` - Then `get_cart` on the `FoodOrder` for "${order_id}" has `entries` of length `1` and `entries[0].quantity=1` + When the `FoodOrder` for "" gets a `add_to_cart` with `item_index=0` and `quantity=0` + Then `get_cart` on the `FoodOrder` for "" has `entries` of length `1` and `entries[0].quantity=1` Scenario: Out-of-range menu indexes are refused Given the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` - When the `FoodOrder` for "${order_id}" attempts a `add_to_cart` with `item_index=10` and `quantity=1` + When the `FoodOrder` for "" attempts a `add_to_cart` with `item_index=10` and `quantity=1` Then the attempt aborts with `Unknown` - When the `FoodOrder` for "${order_id}" attempts a `add_to_cart` with `item_index=-1` and `quantity=1` + When the `FoodOrder` for "" attempts a `add_to_cart` with `item_index=-1` and `quantity=1` Then the attempt aborts with `Unknown` Scenario: Another user cannot touch the order Given the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` When the authenticated user is "bob" - Then `get_cart` on the `FoodOrder` for "${order_id}" aborts with `PermissionDenied` - When the `FoodOrder` for "${order_id}" attempts a `add_to_cart` with `item_index=0` and `quantity=1` + Then `get_cart` on the `FoodOrder` for "" aborts with `PermissionDenied` + When the `FoodOrder` for "" attempts a `add_to_cart` with `item_index=0` and `quantity=1` Then the attempt aborts with `PermissionDenied` diff --git a/reboot/examples/monorepo/bank/backend/tests/account.feature b/reboot/examples/monorepo/bank/backend/tests/account.feature index b32cd7700..cac9b4a12 100644 --- a/reboot/examples/monorepo/bank/backend/tests/account.feature +++ b/reboot/examples/monorepo/bank/backend/tests/account.feature @@ -18,5 +18,5 @@ Feature: Accounts Scenario: Opening sends a welcome email Given an `Account` for "bob" gets created via `open` with `customer_name="Bob"` And the resulting `welcome_email_task_id` is saved as `welcome_email_task_id` - Then the `welcome_email` task with id "${welcome_email_task_id}" of the `Account` completes within 30 seconds + Then the `welcome_email` task with id "" of the `Account` completes within 30 seconds And the welcome email was sent diff --git a/reboot/examples/monorepo/bank/backend/tests/bank.feature b/reboot/examples/monorepo/bank/backend/tests/bank.feature index 8e49b23f7..38f04026c 100644 --- a/reboot/examples/monorepo/bank/backend/tests/bank.feature +++ b/reboot/examples/monorepo/bank/backend/tests/bank.feature @@ -7,17 +7,17 @@ Feature: Bank Scenario: Signing up opens an account When the `Bank` for "my-bank" gets a `sign_up` with `customer_name="Alice"` And the resulting `account_id` is saved as `alice_account_id` - Then `balance` on the `Account` for "${alice_account_id}" has `balance=0` + Then `balance` on the `Account` for "" has `balance=0` Scenario: Transfers move money between accounts Given the `Bank` for "my-bank" gets a `sign_up` with `customer_name="Alice"` And the resulting `account_id` is saved as `alice_account_id` And the `Bank` for "my-bank" gets a `sign_up` with `customer_name="Bob"` And the resulting `account_id` is saved as `bob_account_id` - When the `Account` for "${alice_account_id}" gets a `deposit` with `amount=100` - Then `balance` on the `Account` for "${alice_account_id}" has `balance=100` - When the `Bank` for "my-bank" gets a `transfer` with `from_account_id=${alice_account_id}` and `to_account_id=${bob_account_id}` and `amount=40` - Then `balance` on the `Account` for "${alice_account_id}" has `balance=60` - And `balance` on the `Account` for "${bob_account_id}" has `balance=40` - When the `Bank` for "my-bank" attempts a `transfer` with `from_account_id=${bob_account_id}` and `to_account_id=${alice_account_id}` and `amount=50` + When the `Account` for "" gets a `deposit` with `amount=100` + Then `balance` on the `Account` for "" has `balance=100` + When the `Bank` for "my-bank" gets a `transfer` with `from_account_id=` and `to_account_id=` and `amount=40` + Then `balance` on the `Account` for "" has `balance=60` + And `balance` on the `Account` for "" has `balance=40` + When the `Bank` for "my-bank" attempts a `transfer` with `from_account_id=` and `to_account_id=` and `amount=50` Then the attempt aborts with `OverdraftError` with `amount=10` diff --git a/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature b/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature index 5a0433e08..67178b1ea 100644 --- a/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature +++ b/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature @@ -9,7 +9,7 @@ Feature: Hello with tasks And the resulting `task_id` is saved as `warning_task_id` # A completed task's response is the result, so the erase task's # ID saves from it the way any response property does. - When the `warning` task with id "${warning_task_id}" of the `Hello` completes within 30 seconds + When the `warning` task with id "" of the `Hello` completes within 30 seconds And the resulting `task_id` is saved as `erase_task_id` - And the `erase` task with id "${erase_task_id}" of the `Hello` completes within 30 seconds + And the `erase` task with id "" of the `Hello` completes within 30 seconds Then `messages` on the `Hello` for "testing-hello" has `messages` of length `1` and `messages[0]="Number of messages erased so far: 1"` diff --git a/reboot/examples/reboot-swag-store/backend/tests/store.feature b/reboot/examples/reboot-swag-store/backend/tests/store.feature index 164114443..a1c70ce90 100644 --- a/reboot/examples/reboot-swag-store/backend/tests/store.feature +++ b/reboot/examples/reboot-swag-store/backend/tests/store.feature @@ -57,10 +57,10 @@ Feature: Swag store And the authenticated user is "test-user" And a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` And the `Cart` for "cart-1" gets a `add_item` with `quantity=2` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - When the `Cart` for "cart-1" gets a `checkout` with `coupon_code=${coupon_code}` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + When the `Cart` for "cart-1" gets a `checkout` with `coupon_code=` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` And the resulting `order_id` is saved as `order_id` Then `get_cart` on the `Cart` for "cart-1" has `items=[]` - And `get_details` on the `Order` for "${order_id}" has `order_id=${order_id}` and `items` of length `1` and `items[0].product_id="hoodie-1"` and `items[0].quantity=2` and `subtotal_cents=8000` and `total_cents=0` + And `get_details` on the `Order` for "" has `order_id=` and `items` of length `1` and `items[0].product_id="hoodie-1"` and `items[0].quantity=2` and `subtotal_cents=8000` and `total_cents=0` Scenario: A redeemed coupon cannot be reused Given the bearer token is "test-admin-key" @@ -69,10 +69,10 @@ Feature: Swag store And the authenticated user is "test-user" And a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - And the `Cart` for "cart-1" gets a `checkout` with `coupon_code=${coupon_code}` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + And the `Cart` for "cart-1" gets a `checkout` with `coupon_code=` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` And a `Cart` for "cart-2" gets created via `create` with `owner_id="test-user"` And the `Cart` for "cart-2" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - When the `Cart` for "cart-2" attempts a `checkout` with `coupon_code=${coupon_code}` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + When the `Cart` for "cart-2" attempts a `checkout` with `coupon_code=` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` Then the attempt aborts with `InvalidCoupon` Scenario: Generating coupon codes requires the admin bearer token diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 5f2bf520e..57025c104 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -25,17 +25,17 @@ Feature: Accounts Scenario: Steps can save result properties Given an `Account` for "eve" gets created via `open` with `initial_balance=9` And the resulting `account_id` is saved as `eve_account` - When the `Account` for "${eve_account}" gets a `deposit` with `amount=1` + When the `Account` for "" gets a `deposit` with `amount=1` And the resulting `updated_balance` is saved as `balance` - And the `Account` for "${eve_account}" gets a `deposit` with `amount=${balance}` - When `balance` on the `Account` for "${eve_account}" has `balance` saved as `current` - And the `Account` for "${eve_account}" gets a `deposit` with `amount=${current}` - Then `balance` on the `Account` for "${eve_account}" has `balance=40` + And the `Account` for "" gets a `deposit` with `amount=` + When `balance` on the `Account` for "" has `balance` saved as `current` + And the `Account` for "" gets a `deposit` with `amount=` + Then `balance` on the `Account` for "" has `balance=40` Scenario: Saving during setup Given an `Account` for "gus" gets created via `open` with `initial_balance=7` And `balance` on the `Account` for "gus" has `balance` saved as `initial` and `balance` saved as `twin` - When the `Account` for "gus" gets a `deposit` with `amount=${initial}` + When the `Account` for "gus" gets a `deposit` with `amount=` Then `balance` on the `Account` for "gus" has `balance=14` Scenario: Properties can be messages @@ -49,11 +49,11 @@ Feature: Accounts And `get_owner` on the `Account` for "frank" has `owner.name` containing `"rank"` and `owner.tags` of length `1` And `get_owner` on the `Account` for "frank" has `owner.tags` containing `"pro"` When `get_owner` on the `Account` for "frank" has `owner.name` saved as `owner_name` - And an `Account` for "${owner_name}" gets created via `open` with `initial_balance=1` + And an `Account` for "" gets created via `open` with `initial_balance=1` Then `balance` on the `Account` for "Frankie" has `balance=1` When `get_owner` on the `Account` for "frank" has `owner` saved as `owner` And an `Account` for "franklin" gets created via `open` - And the `Account` for "franklin" gets a `set_owner` with `owner=${owner}` + And the `Account` for "franklin" gets a `set_owner` with `owner=` Then `get_owner` on the `Account` for "franklin" has `owner={name: "Frankie", tags: ["pro"]}` Scenario: Readers can abort @@ -87,12 +87,12 @@ Feature: Accounts Scenario: Spawned tasks complete Given an `Account` for "spawned" gets created via `open` 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 + Then the `deposit` task with id "" of the `Account` completes within 30 seconds And the result has `updated_balance=15` Scenario: Scheduled tasks are awaited by ID Given an `Account` for "later" gets created via `open` When the `Account` for "later" gets a `deposit_later` with `amount=20` And the resulting `task_id` is saved as `deposit_task_id` - And the `deposit` task with id "${deposit_task_id}" of the `Account` completes within 30 seconds + And the `deposit` task with id "" of the `Account` completes within 30 seconds Then the result has `updated_balance=20` diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index 828afc0b3..b460f3dbe 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -100,7 +100,7 @@ def test_the_bearer_token_is() -> None: _the_bearer_token_is(world, 'admin-key') assert world.bearer_token == 'admin-key' world.saved['token'] = 'saved-key' - _the_bearer_token_is(world, '${token}') + _the_bearer_token_is(world, '') assert world.bearer_token == 'saved-key' @@ -171,10 +171,20 @@ def test_almost_clause_messages() -> None: _parse_saves('`balance` saved as "b"') with pytest.raises(ValueError, match="name goes in backticks"): _parse_saves('`balance` saved as b') - with pytest.raises(ValueError, match=r"recall a save as \$\{amount\}"): + with pytest.raises(ValueError, match=r"say a saved value as "): _parse_assignments(world, '`amount=$amount`') +def test_a_save_may_not_use_an_examples_column() -> None: + """A Scenario Outline's column is what already says, so a + save under its name would never be said.""" + world = World(example_columns=frozenset({'amount'})) + with pytest.raises(ValueError, match="column of the scenario's"): + world.save('amount', 5) + world.save('total', 5) + assert world.saved == {'total': 5} + + def test_almost_steps_raise() -> None: with pytest.raises(ValueError, match="with a Then instead"): _almost_asserting_under_given_or_when() diff --git a/tests/reboot/bdd/grammar_tests.py b/tests/reboot/bdd/grammar_tests.py index 5a00207e8..6417d2c2d 100644 --- a/tests/reboot/bdd/grammar_tests.py +++ b/tests/reboot/bdd/grammar_tests.py @@ -24,7 +24,7 @@ class ReadTest(unittest.TestCase): def test_a_call_with_assignments(self) -> None: syntax = parse( 'the `Bank` for "test-bank" gets a `transfer` with ' - '`from_account_id=${first_account_id}` and `amount=250.0` ' + '`from_account_id=` and `amount=250.0` ' 'spawned with its task id saved as `transfer_task_id`' ) assert syntax is not None @@ -39,7 +39,7 @@ def test_a_call_with_assignments(self) -> None: for assignment in gets.assignments ], [ - ('from_account_id', '${first_account_id}'), + ('from_account_id', ''), ('amount', '250.0'), ], ) @@ -95,15 +95,15 @@ def test_predicates_and_saves(self) -> None: def test_a_state_id_can_be_a_variable(self) -> None: syntax = parse( - '`balance` on the `Account` for "${first_account_id}" has ' + '`balance` on the `Account` for "" has ' '`amount=750.0`' ) assert syntax is not None - self.assertEqual(syntax.has.state.id, '${first_account_id}') + self.assertEqual(syntax.has.state.id, '') def test_a_task_completing_recalls_its_id(self) -> None: syntax = parse( - 'the `deposit` task with id "${deposit_task_id}" of the ' + 'the `deposit` task with id "" of the ' '`Account` completes within 30 seconds' ) assert syntax is not None @@ -117,7 +117,7 @@ def test_a_task_completing_recalls_its_id(self) -> None: # A wait bound not of the grammar's form is not a syntax. self.assertIsNone( parse( - 'the `deposit` task with id "${deposit_task_id}" of the ' + 'the `deposit` task with id "" of the ' '`Account` completes within 30s' ) ) diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index 1a7bd3001..0e99cdeff 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -33,7 +33,7 @@ Feature: Accounts with a pydantic API And `get_owner` on the `Account` for "frank" has `owner.tags` containing `"pro"` When `get_owner` on the `Account` for "frank" has `owner` saved as `owner` And an `Account` for "franklin" gets created via `open` - And the `Account` for "franklin" gets a `set_owner` with `owner=${owner}` + And the `Account` for "franklin" gets a `set_owner` with `owner=` Then `get_owner` on the `Account` for "franklin" has `owner={name: "Frankie", tags: ["pro"]}` Scenario: Properties reach through maps @@ -58,5 +58,5 @@ Feature: Accounts with a pydantic API Scenario: Spawned tasks complete Given an `Account` for "spawned" gets created via `open` 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 + Then the `deposit` task with id "" of the `Account` completes within 30 seconds And the result has `updated_balance=15` From 4f5bfa5cf3ef16a4cfc5115a6f4d17c8740644c8 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Sat, 5 Sep 2026 16:34:05 +0000 Subject: [PATCH 29/42] Group bank-pydantic's overdraft outline under a Rule 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- .../bank-pydantic/backend/tests/bank.feature | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/reboot/examples/bank-pydantic/backend/tests/bank.feature b/reboot/examples/bank-pydantic/backend/tests/bank.feature index a5a12fb9a..fab6578fb 100644 --- a/reboot/examples/bank-pydantic/backend/tests/bank.feature +++ b/reboot/examples/bank-pydantic/backend/tests/bank.feature @@ -18,17 +18,6 @@ Feature: Bank And `all_customer_ids` on the `Bank` for "test-bank" has `customer_ids` of length `2` and `customer_ids` containing `"test@reboot.dev"` and `customer_ids` containing `"test2@reboot.dev"` And `account_balances` on the `Bank` for "test-bank" has `balances` of length `2` and `balances[0].customer_id="test@reboot.dev"` and `balances[0].accounts` of length `1` and `balances[0].accounts[0].balance=750.0` and `balances[1].customer_id="test2@reboot.dev"` and `balances[1].accounts` of length `1` and `balances[1].accounts[0].balance=250.0` - Scenario Outline: Overdrafts are refused - Given an `Account` for "" gets created via `open` - When the `Account` for "" gets a `deposit` with `amount=` - And the `Account` for "" attempts a `withdraw` with `amount=` - Then the attempt aborts with `OverdraftError` with `amount=` - - Examples: - | account | deposit | withdrawal | shortfall | - | empty-account | 0.0 | 50.50 | 50.50 | - | funded-account | 20.0 | 50.50 | 30.50 | - Scenario: Spawned deposits and reads complete Given an `Account` for "spawning-account" gets created via `open` When the `Account` for "spawning-account" gets a `deposit` with `amount=10.0` spawned with its task id saved as `deposit_task_id` @@ -36,3 +25,18 @@ Feature: Bank When the `Account` for "spawning-account" gets a `balance` spawned with its task id saved as `balance_task_id` Then the `balance` task with id "" of the `Account` completes within 30 seconds And the result has `amount=10.0` + + Rule: Overdrafts are refused + An account never goes below zero: a withdrawal for more than the + balance aborts, saying by how much it fell short. + + Scenario Outline: Withdrawing more than the balance aborts with the shortfall + Given an `Account` for "" gets created via `open` + When the `Account` for "" gets a `deposit` with `amount=` + And the `Account` for "" attempts a `withdraw` with `amount=` + Then the attempt aborts with `OverdraftError` with `amount=` + + Examples: + | account | deposit | withdrawal | shortfall | + | empty-account | 0.0 | 50.50 | 50.50 | + | funded-account | 20.0 | 50.50 | 30.50 | From aa621f43a60ea3366aa9b18490b487aaee7dbfef Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Sat, 5 Sep 2026 16:50:14 +0000 Subject: [PATCH 30/42] Write bank-pydantic's features as capabilities with rules 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/dashboard/web/dashboard.css | 85 ++++- reboot/dashboard/web/src/behaviors.ts | 7 - reboot/dashboard/web/src/main.tsx | 344 ++++++++++++------ .../bank-pydantic/backend/tests/bank.feature | 42 --- .../backend/tests/deposits.feature | 13 + .../backend/tests/full_bank_test.py | 5 +- .../backend/tests/transfers.feature | 44 +++ .../backend/tests/withdrawals.feature | 28 ++ tests/reboot/bdd/accounts.feature | 3 + 9 files changed, 399 insertions(+), 172 deletions(-) delete mode 100644 reboot/examples/bank-pydantic/backend/tests/bank.feature create mode 100644 reboot/examples/bank-pydantic/backend/tests/deposits.feature create mode 100644 reboot/examples/bank-pydantic/backend/tests/transfers.feature create mode 100644 reboot/examples/bank-pydantic/backend/tests/withdrawals.feature diff --git a/reboot/dashboard/web/dashboard.css b/reboot/dashboard/web/dashboard.css index 8088cc93a..da1ec26b1 100644 --- a/reboot/dashboard/web/dashboard.css +++ b/reboot/dashboard/web/dashboard.css @@ -1666,21 +1666,9 @@ header h1 { font-weight: 600; } -/* Slate: the keyword is structure, not one of the four kinds, so it - stays as muted as the base pill while keeping the pill shape that - makes the rows scan as a column. */ +/* The keyword as an eyebrow, the way a rule's is. */ .scenario-keyword { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 92px; - font-family: ui-monospace, Menlo, monospace; - font-size: 10px; - padding: 2px 8px; - border-radius: 999px; - background: hsl(var(--muted)); - border: 1px solid hsl(240 5.9% 84%); - color: hsl(var(--muted-foreground)); + display: inline-block; } /* The same open-and-close animation as `.method-detail`, on the @@ -1855,11 +1843,16 @@ header h1 { background: hsl(var(--hue, 28) 85% 55% / 0.25); } +/* A rule is a block with a rail down its left: its heading, its + description, and its scenarios all sit inside it, so where one + rule ends and the next begins is plain. */ .rule { margin-top: 26px; + padding-left: 18px; + border-left: 3px solid hsl(var(--border-strong)); display: flex; flex-direction: column; - gap: 10px; + gap: 14px; } .rule-heading { @@ -1873,3 +1866,65 @@ header h1 { margin: 0; font-size: 17px; } + +/* The rule's description as its subtitle: close under the heading, + smaller and muted, so it reads as being about the rule. */ +.rule-description { + margin: -8px 0 0; + max-width: 62ch; + font-size: 12.5px; + line-height: 1.55; + color: hsl(var(--muted-foreground)); +} + +.rule-description code { + font-family: ui-monospace, Menlo, monospace; + font-size: 0.92em; +} + +/* --- The behaviors index --- */ + +/* On a feature's own page the pane's header names it and carries its + file, counts, and description, above the line. */ +header .feature-file-line { + display: flex; + align-items: baseline; + gap: 12px; + margin-top: 10px; +} + +header .state-type-description { + margin-top: 14px; +} + +/* Names linking to their pages under a heading: the behaviors + index's two columns. */ +.link-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.link-list a { + color: inherit; + text-decoration: none; + font-weight: 600; +} + +.link-list a:hover { + color: hsl(var(--primary)); + text-decoration: underline; +} + +/* The two lists side by side, each taking half the pane. */ +.behaviors-index { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + max-width: 840px; + margin: 0 auto; +} + +.behaviors-index .link-list a { + font-size: 15px; +} diff --git a/reboot/dashboard/web/src/behaviors.ts b/reboot/dashboard/web/src/behaviors.ts index 993972527..1b1e3667e 100644 --- a/reboot/dashboard/web/src/behaviors.ts +++ b/reboot/dashboard/web/src/behaviors.ts @@ -30,13 +30,6 @@ export const scenariosOfFeature = ( ...feature.rules.flatMap((rule) => rule.scenarios), ]; -// The directory a feature file is in, which is how the sidebar groups -// features, the way packages group types. -export const directoryOfFeature = (filename: string): string => { - const slash = filename.lastIndexOf("/"); - return slash === -1 ? "." : filename.slice(0, slash); -}; - // Where the backticked spans of steps can link: each state type's // short name mapped to its id on the state page, and each method name // mapped to every state type declaring one by that name. diff --git a/reboot/dashboard/web/src/main.tsx b/reboot/dashboard/web/src/main.tsx index 177476836..445c631ce 100644 --- a/reboot/dashboard/web/src/main.tsx +++ b/reboot/dashboard/web/src/main.tsx @@ -40,10 +40,15 @@ import { } from "./constants"; import type * as api_pb from "../../../../rbt/v1alpha1/api/api_pb"; import type * as dashboard_pb from "../../../../rbt/dashboard/v1/dashboard_pb"; -import type { Features, Printed, Span, StepLinks } from "./behaviors"; +import type { + FeatureEntry, + Features, + Printed, + Span, + StepLinks, +} from "./behaviors"; import { columnsOfExamples, - directoryOfFeature, hueKeyOfSpan, hueKeyOfVariable, huesOfScenario, @@ -136,17 +141,20 @@ const DEFINITIONS: Record = { const DEFINITION_GAP = 8; // A pill that shows its definition on hover, when it has one. The -// mark on the pill tells the reader a definition exists. +// mark on the pill tells the reader a definition exists; a label +// set as an eyebrow leaves the mark off, since a row of eyebrows +// each trailing a mark reads as clutter. // // The definition opens above the pill so it does not cover the row // the reader is on. The pane clips content outside it, so when the // pane is scrolled and there is no room above, the definition opens // below the pill instead. -const Pill: FC<{ className: string; label: string; meaning?: string }> = ({ - className, - label, - meaning, -}) => { +const Pill: FC<{ + className: string; + label: string; + meaning?: string; + mark?: boolean; +}> = ({ className, label, meaning, mark = true }) => { const pill = useRef(null); const [below, setBelow] = useState(false); @@ -177,9 +185,11 @@ const Pill: FC<{ className: string; label: string; meaning?: string }> = ({ onFocus={place} > {label} - + {mark && ( + + )} {expanded ? "â–¾" : "â–¸"} - + {name} {tags.length > 0 && ( @@ -1187,7 +1202,10 @@ const ScenarioRows: FC<{ )} {scenarios.map((scenario) => ( ); +// Both the route a link to a rule goes to and the `id` of its +// section: the feature's file, then which of its rules, counting +// from one, since a rule may have no name. +const ruleId = (filename: string, index: number): string => + `${filename}/rules/${index + 1}`; + const RuleSection: FC<{ rule: feature_pb.Rule; + // The rule's id on the page, a `ruleId`. + id: string; inherited: feature_pb.Background[]; links: StepLinks; -}> = ({ rule, inherited, links }) => ( -
+}> = ({ rule, id, inherited, links }) => ( +

{rule.name}

+ {countWithNoun(rule.scenarios.length, "scenario")}
{rule.description !== undefined && ( - + )} ); +// A feature on its own page. The pane's header names it and carries +// its file, counts, and description, so the card holds the +// scenarios and rules. const FeatureCard: FC<{ filename: string; feature: feature_pb.Feature; links: StepLinks; -}> = ({ filename, feature, links }) => { - const scenarios = scenariosOfFeature(feature); +}> = ({ filename, feature, links }) => ( +
+ {feature.error !== undefined ? ( +
{feature.error}
+ ) : ( + <> + {(feature.background !== undefined || feature.scenarios.length > 0) && ( + + )} + {feature.rules.map((rule, index) => ( + + ))} + + )} +
+); + +// One name that links to a page, on the behaviors index and in its +// sidebar. +interface NamedLink { + id: string; + name: string; +} + +// Every feature, and every rule, each linking to its page. +const namedLinksOf = ( + features: FeatureEntry[] +): { features: NamedLink[]; rules: NamedLink[] } => ({ + features: features.map(({ filename, feature }) => ({ + id: filename, + name: feature.name ?? filename, + })), + rules: features.flatMap(({ filename, feature }) => + feature.rules.map((rule, index) => ({ + id: ruleId(filename, index), + name: rule.name ?? `Rule ${index + 1}`, + })) + ), +}); + +// The sidebar's list of names linking to their pages, under a +// heading: rows of the sidebar's grid, so an eyebrow and a name cell +// each, with no count. +const NavLinks: FC<{ heading: string; links: NamedLink[] }> = ({ + heading, + links, +}) => ( + <> +
{heading}
+ {links.map((link) => ( + + {link.name} + + ))} + +); + +// The index's list of names linking to their pages, under a heading. +const LinkList: FC<{ heading: string; links: NamedLink[] }> = ({ + heading, + links, +}) => ( +
+
{heading}
+ {links.length === 0 ? ( +
None yet.
+ ) : ( + links.map((link) => ( + + {link.name} + + )) + )} +
+); + +// The behaviors page with no feature chosen: the features and the +// rules, side by side, each name linking to its page. +const FeaturesIndex: FC<{ features: FeatureEntry[] }> = ({ features }) => { + const links = namedLinksOf(features); return ( -
-
- -
-
-
-

{feature.name ?? filename}

- - - {countWithNoun(scenarios.length, "scenario")} - {feature.rules.length > 0 && - ` · ${countWithNoun(feature.rules.length, "rule")}`} - -
-
-
{filename}
- {feature.error !== undefined ? ( -
{feature.error}
- ) : ( - <> - {feature.description !== undefined && ( - - )} - {(feature.background !== undefined || - feature.scenarios.length > 0) && ( - - )} - {feature.rules.map((rule, index) => ( - - ))} - - )} -
+
+ + +
); }; @@ -1503,6 +1578,20 @@ const Overview: FC<{ const featureEntries = useMemo(() => sortedFeatures(features), [features]); + // The feature the URL names, by its file or by one of its rules + // (`ruleId`); `undefined` for the page with no feature chosen, + // which lists them all. + const chosenFeature = useMemo( + () => + target === undefined + ? undefined + : featureEntries.find( + ({ filename }) => + target === filename || target.startsWith(`${filename}/rules/`) + ), + [featureEntries, target] + ); + const scenarioCount = useMemo( () => featureEntries.reduce( @@ -1512,6 +1601,21 @@ const Overview: FC<{ [featureEntries] ); + // Rules are what the page counts by once a project writes them; + // until then, scenarios. + const ruleCount = useMemo( + () => + featureEntries.reduce( + (total, entry) => total + entry.feature.rules.length, + 0 + ), + [featureEntries] + ); + const behaviorsCount = + ruleCount > 0 + ? countWithNoun(ruleCount, "rule") + : countWithNoun(scenarioCount, "scenario"); + // Where the backticked spans of steps link, derived from the same // APIs the state page shows, so a link can never point at a state // type the page does not have. @@ -1529,9 +1633,9 @@ const Overview: FC<{ }, [apis]); // The changelog is one list rather than a set of packages, and - // the graph is one canvas, so the sidebar has nothing to index. - // The behaviors page's "packages" are the directories the feature - // files are in. + // the graph is one canvas, so the sidebar has nothing to index; + // the behaviors page indexes its features and rules as two flat + // lists of its own, below. const entries: NavEntry[] = useMemo( () => page === "changelog" || page === "graph" @@ -1546,15 +1650,7 @@ const Overview: FC<{ })) ) : page === "behaviors" - ? featureEntries.map(({ filename, feature }) => ({ - id: filename, - name: feature.name ?? filename, - package: directoryOfFeature(filename), - count: countWithNoun( - scenariosOfFeature(feature).length, - "scenario" - ), - })) + ? [] : linkedDataTypes.map((linkedDataType) => ({ id: linkedDataType.id, name: linkedDataType.name, @@ -1566,6 +1662,12 @@ const Overview: FC<{ const packages = useMemo(() => groupByPackage(entries), [entries]); + // The behaviors sidebar's two lists. + const behaviorLinks = useMemo( + () => namedLinksOf(featureEntries), + [featureEntries] + ); + // The changelog, read here rather than in its page so that the // nav's count is right before the page is ever opened. const { useReverseRange } = useOrderedMap({ id: CHANGELOG_ID }); @@ -1597,7 +1699,9 @@ const Overview: FC<{ page === "changelog" ? "history" : page === "behaviors" - ? "application behavior" + ? chosenFeature === undefined + ? "application behavior" + : "feature" : "application domain"; const heading = @@ -1614,10 +1718,12 @@ const Overview: FC<{ "package" )}` : page === "behaviors" - ? `${countWithNoun(scenarioCount, "scenario")} in ${countWithNoun( - featureEntries.length, - "feature" - )}` + ? chosenFeature === undefined + ? `${behaviorsCount} in ${countWithNoun( + featureEntries.length, + "feature" + )}` + : chosenFeature.feature.name ?? chosenFeature.filename : `${countWithNoun( linkedDataTypes.length, "data type" @@ -1719,7 +1825,7 @@ const Overview: FC<{ const counts: Record = { state: stateTypeCount, data: linkedDataTypes.length, - behaviors: scenarioCount, + behaviors: ruleCount > 0 ? ruleCount : scenarioCount, changelog: shownChangelog.length, graph: calls, }; @@ -1747,19 +1853,19 @@ const Overview: FC<{
+ ) : chosenFeature === undefined ? ( + ) : ( - featureEntries.map(({ filename, feature }) => ( - - )) + ) ) : linkedDataTypes.length === 0 ? (
diff --git a/reboot/examples/bank-pydantic/backend/tests/bank.feature b/reboot/examples/bank-pydantic/backend/tests/bank.feature deleted file mode 100644 index fab6578fb..000000000 --- a/reboot/examples/bank-pydantic/backend/tests/bank.feature +++ /dev/null @@ -1,42 +0,0 @@ -Feature: Bank - - Background: - Given the application is up - And the user is unauthenticated - - Scenario: Transfers move money between accounts - Given a `Bank` for "test-bank" gets created via `create` - When the `Bank` for "test-bank" gets a `sign_up` with `customer_id="test@reboot.dev"` - And the `Customer` for "test@reboot.dev" gets a `open_account` with `initial_deposit=1000.0` - And the resulting `account_id` is saved as `first_account_id` - And the `Bank` for "test-bank" gets a `sign_up` with `customer_id="test2@reboot.dev"` - And the `Customer` for "test2@reboot.dev" gets a `open_account` with `initial_deposit=0.0` - And the resulting `account_id` is saved as `second_account_id` - And the `Bank` for "test-bank" gets a `transfer` with `from_account_id=` and `to_account_id=` and `amount=250.0` - Then `balance` on the `Account` for "" has `amount=750.0` - And `balance` on the `Account` for "" has `amount=250.0` - And `all_customer_ids` on the `Bank` for "test-bank" has `customer_ids` of length `2` and `customer_ids` containing `"test@reboot.dev"` and `customer_ids` containing `"test2@reboot.dev"` - And `account_balances` on the `Bank` for "test-bank" has `balances` of length `2` and `balances[0].customer_id="test@reboot.dev"` and `balances[0].accounts` of length `1` and `balances[0].accounts[0].balance=750.0` and `balances[1].customer_id="test2@reboot.dev"` and `balances[1].accounts` of length `1` and `balances[1].accounts[0].balance=250.0` - - Scenario: Spawned deposits and reads complete - Given an `Account` for "spawning-account" gets created via `open` - When the `Account` for "spawning-account" gets a `deposit` with `amount=10.0` spawned with its task id saved as `deposit_task_id` - Then the `deposit` task with id "" of the `Account` completes within 30 seconds - When the `Account` for "spawning-account" gets a `balance` spawned with its task id saved as `balance_task_id` - Then the `balance` task with id "" of the `Account` completes within 30 seconds - And the result has `amount=10.0` - - Rule: Overdrafts are refused - An account never goes below zero: a withdrawal for more than the - balance aborts, saying by how much it fell short. - - Scenario Outline: Withdrawing more than the balance aborts with the shortfall - Given an `Account` for "" gets created via `open` - When the `Account` for "" gets a `deposit` with `amount=` - And the `Account` for "" attempts a `withdraw` with `amount=` - Then the attempt aborts with `OverdraftError` with `amount=` - - Examples: - | account | deposit | withdrawal | shortfall | - | empty-account | 0.0 | 50.50 | 50.50 | - | funded-account | 20.0 | 50.50 | 30.50 | diff --git a/reboot/examples/bank-pydantic/backend/tests/deposits.feature b/reboot/examples/bank-pydantic/backend/tests/deposits.feature new file mode 100644 index 000000000..6c634ba21 --- /dev/null +++ b/reboot/examples/bank-pydantic/backend/tests/deposits.feature @@ -0,0 +1,13 @@ +Feature: Depositing into an account + A customer puts money into an account and sees it in the balance. + + Background: + Given the application is up + And the user is unauthenticated + + Rule: A deposit raises the balance by the amount + + Scenario: A deposit into a new account + Given an `Account` for "new-account" gets created via `open` + When the `Account` for "new-account" gets a `deposit` with `amount=10.0` + Then `balance` on the `Account` for "new-account" has `amount=10.0` diff --git a/reboot/examples/bank-pydantic/backend/tests/full_bank_test.py b/reboot/examples/bank-pydantic/backend/tests/full_bank_test.py index 02060709b..6d6c17687 100644 --- a/reboot/examples/bank-pydantic/backend/tests/full_bank_test.py +++ b/reboot/examples/bank-pydantic/backend/tests/full_bank_test.py @@ -1,4 +1,5 @@ -"""The bank's tests: the Gherkin scenarios in `bank.feature`. +"""The bank's tests: the Gherkin scenarios in the `.feature` files +beside this module. The scenarios run against servicer subclasses that wire up authorizers, showing how a test exercises authorization, and that @@ -138,4 +139,4 @@ def application() -> Application: ) -scenarios('bank.feature') +scenarios('transfers.feature', 'deposits.feature', 'withdrawals.feature') diff --git a/reboot/examples/bank-pydantic/backend/tests/transfers.feature b/reboot/examples/bank-pydantic/backend/tests/transfers.feature new file mode 100644 index 000000000..ea6818b96 --- /dev/null +++ b/reboot/examples/bank-pydantic/backend/tests/transfers.feature @@ -0,0 +1,44 @@ +Feature: Transferring money between accounts + A customer moves money from one of their accounts to another + account of the bank in one step, which is how they pay someone + without a withdrawal and a deposit that could come apart. + + Background: + Given the application is up + And the user is unauthenticated + + Rule: A transfer moves exactly the amount from one account to the other + Neither account sees any other change, and the bank's view of + every customer's balances agrees with each account's own. + + Scenario: A transfer between two customers' accounts + Given a `Bank` for "test-bank" gets created via `create` + When the `Bank` for "test-bank" gets a `sign_up` with `customer_id="test@reboot.dev"` + And the `Customer` for "test@reboot.dev" gets an `open_account` with `initial_deposit=1000.0` + And the resulting `account_id` is saved as `first_account_id` + And the `Bank` for "test-bank" gets a `sign_up` with `customer_id="test2@reboot.dev"` + And the `Customer` for "test2@reboot.dev" gets an `open_account` with `initial_deposit=0.0` + And the resulting `account_id` is saved as `second_account_id` + And the `Bank` for "test-bank" gets a `transfer` with `from_account_id=` and `to_account_id=` and `amount=250.0` + Then `balance` on the `Account` for "" has `amount=750.0` + And `balance` on the `Account` for "" has `amount=250.0` + And `all_customer_ids` on the `Bank` for "test-bank" has `customer_ids` of length `2` and `customer_ids` containing `"test@reboot.dev"` and `customer_ids` containing `"test2@reboot.dev"` + And `account_balances` on the `Bank` for "test-bank" has `balances` of length `2` and `balances[0].customer_id="test@reboot.dev"` and `balances[0].accounts` of length `1` and `balances[0].accounts[0].balance=750.0` and `balances[1].customer_id="test2@reboot.dev"` and `balances[1].accounts` of length `1` and `balances[1].accounts[0].balance=250.0` + + Rule: A transfer that would overdraw the source leaves both accounts unchanged + A transfer is one transaction: when the withdrawal from the source + account aborts, the deposit into the destination is rolled back + too, so money is never created by a failed transfer. + + Scenario: A transfer for more than the source account holds + Given a `Bank` for "test-bank" gets created via `create` + When the `Bank` for "test-bank" gets a `sign_up` with `customer_id="payer@reboot.dev"` + And the `Customer` for "payer@reboot.dev" gets an `open_account` with `initial_deposit=100.0` + And the resulting `account_id` is saved as `payer_account_id` + And the `Bank` for "test-bank" gets a `sign_up` with `customer_id="payee@reboot.dev"` + And the `Customer` for "payee@reboot.dev" gets an `open_account` with `initial_deposit=0.0` + And the resulting `account_id` is saved as `payee_account_id` + And the `Bank` for "test-bank" attempts a `transfer` with `from_account_id=` and `to_account_id=` and `amount=250.0` + Then the attempt aborts with `Unknown` + And `balance` on the `Account` for "" has `amount=100.0` + And `balance` on the `Account` for "" has `amount=0.0` diff --git a/reboot/examples/bank-pydantic/backend/tests/withdrawals.feature b/reboot/examples/bank-pydantic/backend/tests/withdrawals.feature new file mode 100644 index 000000000..0b1722537 --- /dev/null +++ b/reboot/examples/bank-pydantic/backend/tests/withdrawals.feature @@ -0,0 +1,28 @@ +Feature: Withdrawing from an account + A customer takes money out of an account, but never more than the + account holds. + + Background: + Given the application is up + And the user is unauthenticated + + Scenario: Withdrawing part of the balance leaves the rest + Given an `Account` for "part-account" gets created via `open` + When the `Account` for "part-account" gets a `deposit` with `amount=100.0` + And the `Account` for "part-account" gets a `withdraw` with `amount=40.0` + Then `balance` on the `Account` for "part-account" has `amount=60.0` + + Rule: Overdrafts are refused + An account never goes below zero: a withdrawal for more than the + balance aborts, saying by how much it fell short. + + Scenario Outline: Withdrawing more than the balance aborts with the shortfall + Given an `Account` for "" gets created via `open` + When the `Account` for "" gets a `deposit` with `amount=` + And the `Account` for "" attempts a `withdraw` with `amount=` + Then the attempt aborts with `OverdraftError` with `amount=` + + Examples: + | account | deposit | withdrawal | shortfall | + | empty-account | 0.0 | 50.50 | 50.50 | + | funded-account | 20.0 | 50.50 | 30.50 | diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 57025c104..3230df5f8 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -89,6 +89,9 @@ Feature: Accounts 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 "" of the `Account` completes within 30 seconds And the result has `updated_balance=15` + When the `Account` for "spawned" gets a `balance` spawned with its task id saved as `read` + Then the `balance` task with id "" of the `Account` completes within 30 seconds + And the result has `balance=15` Scenario: Scheduled tasks are awaited by ID Given an `Account` for "later" gets created via `open` From b675fbe31b8b3d0cb14db6b2ca5f37f351e39b14 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Sat, 5 Sep 2026 19:16:08 +0000 Subject: [PATCH 31/42] Nest a pydantic API's declared errors in each method's errors message 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 `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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/pydantic_schema_to_proto.py | 35 ++-- reboot/templates/reboot.py.j2 | 9 ++ tests/reboot/dashboard/BUILD.bazel | 1 + .../reboot/dashboard/api/shop/v1/warehouse.py | 28 ++++ tests/reboot/dashboard/pydantic_api_tests.py | 56 ++++++- tests/reboot/echo_rbt.golden.py | 153 ++++++++++++++++++ tests/reboot/greeter_rbt.golden.js | 2 +- tests/reboot/greeter_rbt.golden.py | 144 +++++++++++++++++ tests/reboot/ping_api_rbt.golden.py | 135 ++++++++++++++++ .../reboot/pydantic/shared_error/BUILD.bazel | 57 +++++++ .../pydantic/shared_error/account_api.py | 60 +++++++ .../reboot/pydantic/shared_error/bank_api.py | 37 +++++ .../reboot/pydantic/shared_error/servicers.py | 82 ++++++++++ tests/reboot/pydantic/shared_error/test.py | 69 ++++++++ 14 files changed, 848 insertions(+), 20 deletions(-) create mode 100644 tests/reboot/dashboard/api/shop/v1/warehouse.py create mode 100644 tests/reboot/pydantic/shared_error/BUILD.bazel create mode 100644 tests/reboot/pydantic/shared_error/account_api.py create mode 100644 tests/reboot/pydantic/shared_error/bank_api.py create mode 100644 tests/reboot/pydantic/shared_error/servicers.py create mode 100644 tests/reboot/pydantic/shared_error/test.py diff --git a/reboot/pydantic_schema_to_proto.py b/reboot/pydantic_schema_to_proto.py index e8b108595..c6ff69728 100644 --- a/reboot/pydantic_schema_to_proto.py +++ b/reboot/pydantic_schema_to_proto.py @@ -567,8 +567,6 @@ async def generate_from_api( `protoc` is handed.""" schemas = api.schemas - generated_errors_names = set() - await proto.write('syntax = "proto3";\n') await proto.write(f'package {api.package};\n') await proto.write('import "google/protobuf/empty.proto";\n') @@ -673,27 +671,28 @@ async def generate_from_api( for method in state_type.methods: method_name = method.name if method.errors: + # Match the Zod errors definition by creating a + # top-level message for the method which has declared + # errors. That message has a 'oneof' field with all + # possible error types, each a message nested in it: a + # copy per method, the way every other model a method + # mentions is copied, so that an error model shared + # between methods or API files is never defined twice + # in one package. The Pydantic model, named by + # `pydantic_type`, is what identifies the error; a + # propagated abort is rebuilt from it. + await proto.write('\n') + await proto.write( + f'message {type_name}{to_pascal_case(method_name)}Errors {{\n' + ) for error in method.errors: - error_type_name = schemas[error.name].name - if error_type_name in generated_errors_names: - continue - generated_errors_names.add(error_type_name) await generate_from_schema( proto, schemas[error.name], - name=error_type_name, + name=schemas[error.name].name, schemas=schemas, ) - await proto.write('\n') - - # Match the Zod errors definition by creating a - # top-level message for the method which has declared errors. - # That message will have a 'oneof' field with all possible - # error types. - await proto.write('\n') - await proto.write( - f'message {type_name}{to_pascal_case(method_name)}Errors {{ oneof type {{\n' - ) + await proto.write('\n oneof type {\n') error_tag = 1 for error in method.errors: @@ -703,7 +702,7 @@ async def generate_from_api( f' [ (rbt.v1alpha1.field).pydantic_type = "{error.name}"];\n' ) error_tag += 1 - await proto.write('}}\n\n') + await proto.write(' }\n}\n\n') # Generate RPC service block (regular methods # only — UI methods have no RPC). diff --git a/reboot/templates/reboot.py.j2 b/reboot/templates/reboot.py.j2 index f11446176..02e32066a 100644 --- a/reboot/templates/reboot.py.j2 +++ b/reboot/templates/reboot.py.j2 @@ -1147,6 +1147,15 @@ class {{ state.proto.name }}ServicerMiddleware(IMPORT_reboot_aio_internals_middl logger.warning( f"Propagating unhandled but declared error (in '{{ state.proto.full_name }}.{{ method.proto.name }}') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) diff --git a/tests/reboot/dashboard/BUILD.bazel b/tests/reboot/dashboard/BUILD.bazel index 10f310c77..1367db2d1 100644 --- a/tests/reboot/dashboard/BUILD.bazel +++ b/tests/reboot/dashboard/BUILD.bazel @@ -31,6 +31,7 @@ py_test( main = "pydantic_api_tests.py", deps = [ "//reboot:pydantic_api_py", + "//reboot:pydantic_schema_to_proto_py", "@com_github_reboot_dev_reboot//rbt/v1alpha1/api:api_py_proto", ], ) diff --git a/tests/reboot/dashboard/api/shop/v1/warehouse.py b/tests/reboot/dashboard/api/shop/v1/warehouse.py new file mode 100644 index 000000000..561e1e8bb --- /dev/null +++ b/tests/reboot/dashboard/api/shop/v1/warehouse.py @@ -0,0 +1,28 @@ +"""A second API file in the shop's package, whose `pick` declares the +error `shop.py` defines and declares.""" +from reboot.api import API, Field, Methods, Model, Transaction, Type +from shop.v1.shop import OutOfStockError, StockRequest + + +class WarehouseState(Model): + pallets: int = Field(tag=1) + + +WarehouseMethods = Methods( + create=Transaction(request=None, response=None, factory=True, mcp=None), + pick=Transaction( + request=StockRequest, + response=None, + errors=[OutOfStockError], + description="Take stock off a pallet.", + mcp=None, + ), +) + +api = API( + Warehouse=Type( + state=WarehouseState, + methods=WarehouseMethods, + description="Where the shop's stock waits.", + ) +) diff --git a/tests/reboot/dashboard/pydantic_api_tests.py b/tests/reboot/dashboard/pydantic_api_tests.py index b9b675a79..979323344 100644 --- a/tests/reboot/dashboard/pydantic_api_tests.py +++ b/tests/reboot/dashboard/pydantic_api_tests.py @@ -1,5 +1,7 @@ """`api_of` reads an API file into the grammar `rbt generate` prints -from and the dashboard describes.""" +from and the dashboard describes; `generate_from_api` prints the +proto from it.""" +import asyncio import importlib import os import sys @@ -7,6 +9,7 @@ from pathlib import Path from rbt.v1alpha1.api.api_pb2 import API from reboot.pydantic_api import api_of +from reboot.pydantic_schema_to_proto import generate_from_api API_DIRECTORY = str(Path(__file__).parent / 'api') @@ -20,8 +23,59 @@ def _read(filename: str) -> API: return api_of(module.api, filename=filename) +class _Written: + """What `generate_from_api` wrote, as one string.""" + + def __init__(self) -> None: + self.parts: list[str] = [] + + async def write(self, part: str) -> None: + self.parts.append(part) + + def __str__(self) -> str: + return ''.join(self.parts) + + +def _proto_of(filename: str) -> str: + written = _Written() + asyncio.run(generate_from_api(written, _read(filename))) + return str(written) + + class PydanticApiTest(unittest.TestCase): + def test_reads_an_error_another_file_defines(self) -> None: + declared = _read('shop/v1/warehouse.py') + + [warehouse] = declared.state_types + _, pick = warehouse.methods + self.assertEqual( + [error.name for error in pick.errors], + ['shop.v1.shop.OutOfStockError'], + ) + + def test_prints_each_error_inside_its_methods_message(self) -> None: + """An error is a message nested in the declaring method's own, + so one defined in another file is copied, not imported.""" + proto = _proto_of('shop/v1/warehouse.py') + + self.assertNotIn('import "shop/v1/', proto) + self.assertIn( + 'message WarehousePickErrors {\n' + 'message OutOfStockError {\n' + ' optional string item = 1 [(rbt.v1alpha1.field).required = true];\n' + '}\n' + '\n' + ' oneof type {\n' + ' OutOfStockError out_of_stock_error = 1 ' + ' [ (rbt.v1alpha1.field).pydantic_type = ' + '"shop.v1.shop.OutOfStockError"];\n' + ' }\n' + '}\n', + proto, + ) + self.assertEqual(proto.count('message OutOfStockError {'), 1) + def test_reads_what_the_file_declares(self) -> None: declared = _read('shop/v1/shop.py') diff --git a/tests/reboot/echo_rbt.golden.py b/tests/reboot/echo_rbt.golden.py index 7cfb47554..51d647507 100755 --- a/tests/reboot/echo_rbt.golden.py +++ b/tests/reboot/echo_rbt.golden.py @@ -3352,6 +3352,15 @@ async def __Reply( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.Reply') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -3851,6 +3860,15 @@ async def __Replay( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.Replay') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -4322,6 +4340,15 @@ async def __WaitFor( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.WaitFor') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -4778,6 +4805,15 @@ async def __Stream( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.Stream') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -5098,6 +5134,15 @@ async def __RegexStream( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.RegexStream') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -5440,6 +5485,15 @@ async def __SearchAndReplace( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.SearchAndReplace') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -5944,6 +5998,15 @@ async def __FailOnceShouldBeRetried( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.FailOnceShouldBeRetried') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -6443,6 +6506,15 @@ async def __FailOnceShouldBeRetriedWorkflow( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.FailOnceShouldBeRetriedWorkflow') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -6848,6 +6920,15 @@ async def __TooManyTasks( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.TooManyTasks') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -7347,6 +7428,15 @@ async def __Hanging( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.Hanging') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -7747,6 +7837,15 @@ async def __ReactiveWorkflow( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.ReactiveWorkflow') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -8147,6 +8246,15 @@ async def __ControlLoop( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.ControlLoop') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -8547,6 +8655,15 @@ async def __AtMostOnceWorkflow( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.AtMostOnceWorkflow') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -8947,6 +9064,15 @@ async def __WorkflowCallingWorkflow( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.WorkflowCallingWorkflow') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -9352,6 +9478,15 @@ async def __RaiseValueError( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.RaiseValueError') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -9856,6 +9991,15 @@ async def __RaiseSpecifiedError( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.RaiseSpecifiedError') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -10355,6 +10499,15 @@ async def __FailingWorkflow( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Echo.FailingWorkflow') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) diff --git a/tests/reboot/greeter_rbt.golden.js b/tests/reboot/greeter_rbt.golden.js index 8b3d3bce9..2378c64ab 100755 --- a/tests/reboot/greeter_rbt.golden.js +++ b/tests/reboot/greeter_rbt.golden.js @@ -5509,6 +5509,6 @@ Greeter._ConstructIdempotently = (_j = class { export function importPys() { reboot_native.importPy("tests.reboot.greeter_pb2", "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"); reboot_native.importPy("tests.reboot.greeter_pb2_grpc", "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"); - reboot_native.importPy("tests.reboot.greeter_rbt", "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"); + reboot_native.importPy("tests.reboot.greeter_rbt", "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"); } importPys(); diff --git a/tests/reboot/greeter_rbt.golden.py b/tests/reboot/greeter_rbt.golden.py index 7b9e44698..21616f575 100755 --- a/tests/reboot/greeter_rbt.golden.py +++ b/tests/reboot/greeter_rbt.golden.py @@ -3697,6 +3697,15 @@ async def __Create( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.Create') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -4196,6 +4205,15 @@ async def __Greet( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.Greet') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -4675,6 +4693,15 @@ async def __SetAdjective( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.SetAdjective') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -5174,6 +5201,15 @@ async def __TransactionSetAdjective( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.TransactionSetAdjective') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -5674,6 +5710,15 @@ async def __TryToConstructContext( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.TryToConstructContext') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -6148,6 +6193,15 @@ async def __TryToConstructExternalContext( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.TryToConstructExternalContext') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -6622,6 +6676,15 @@ async def __TestLongRunningFetch( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.TestLongRunningFetch') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -7101,6 +7164,15 @@ async def __TestLongRunningWriter( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.TestLongRunningWriter') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -7600,6 +7672,15 @@ async def __GetWholeState( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.GetWholeState') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -8074,6 +8155,15 @@ async def __FailWithException( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.FailWithException') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -8548,6 +8638,15 @@ async def __FailWithAborted( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.FailWithAborted') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -9022,6 +9121,15 @@ async def __Workflow( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.Workflow') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -9427,6 +9535,15 @@ async def __DangerousFields( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.DangerousFields') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -9931,6 +10048,15 @@ async def __StoreRecursiveMessage( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.StoreRecursiveMessage') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -10430,6 +10556,15 @@ async def __ReadRecursiveMessage( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.ReadRecursiveMessage') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -10904,6 +11039,15 @@ async def __ConstructAndStoreRecursiveMessage( logger.warning( f"Propagating unhandled but declared error (in 'tests.reboot.Greeter.ConstructAndStoreRecursiveMessage') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) diff --git a/tests/reboot/ping_api_rbt.golden.py b/tests/reboot/ping_api_rbt.golden.py index 81ef180c1..6780b9ba3 100755 --- a/tests/reboot/ping_api_rbt.golden.py +++ b/tests/reboot/ping_api_rbt.golden.py @@ -1618,6 +1618,15 @@ async def __DoPing( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.Ping.DoPing') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -2118,6 +2127,15 @@ async def __DoPingPeriodically( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.Ping.DoPingPeriodically') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -2519,6 +2537,15 @@ async def __Describe( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.Ping.Describe') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -2994,6 +3021,15 @@ async def __NumPings( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.Ping.NumPings') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -4082,6 +4118,15 @@ async def __DoPong( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.Pong.DoPong') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -4582,6 +4627,15 @@ async def __NumPongs( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.Pong.NumPongs') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -6098,6 +6152,15 @@ async def __CreateCounter( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.User.CreateCounter') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -6601,6 +6664,15 @@ async def __ListCounters( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.User.ListCounters') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -7076,6 +7148,15 @@ async def __Whoami( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.User.Whoami') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -7555,6 +7636,15 @@ async def __Create( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.User.Create') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -8061,6 +8151,15 @@ async def __SetClaims( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.User.SetClaims') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -9492,6 +9591,15 @@ async def __Create( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.Counter.Create') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -10007,6 +10115,15 @@ async def __Increment( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.Counter.Increment') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -10507,6 +10624,15 @@ async def __Value( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.Counter.Value') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) @@ -10982,6 +11108,15 @@ async def __Description( logger.warning( f"Propagating unhandled but declared error (in 'reboot.ping.Counter.Description') {aborted}" ) + # Raised as this method's own aborted so that the + # error travels the wire the way this method + # declares it, which for a pydantic API is inside + # a message of this method's own; the caller's + # client only decodes that. + raise aborted_type( + aborted.error, + message=aborted.message, + ) from aborted elif ( aborted_type is None or not isinstance(aborted, aborted_type) diff --git a/tests/reboot/pydantic/shared_error/BUILD.bazel b/tests/reboot/pydantic/shared_error/BUILD.bazel new file mode 100644 index 000000000..261f85514 --- /dev/null +++ b/tests/reboot/pydantic/shared_error/BUILD.bazel @@ -0,0 +1,57 @@ +load("@rules_python//python:defs.bzl", "py_library", "py_test") +load("//reboot:pydantic_to_proto.bzl", "py_reboot_library_from_pydantic") + +py_library( + name = "account_api_py", + srcs = ["account_api.py"], + deps = [ + "//reboot:api_py", + ], +) + +py_library( + name = "bank_api_py", + srcs = ["bank_api.py"], + deps = [ + ":account_api_py", + "//reboot:api_py", + ], +) + +py_reboot_library_from_pydantic( + name = "account_py_reboot", + py_deps = [ + ":account_api_py", + ], + pydantic = ":account_api.py", +) + +py_reboot_library_from_pydantic( + name = "bank_py_reboot", + py_deps = [ + ":bank_api_py", + ], + pydantic = ":bank_api.py", +) + +py_library( + name = "servicers_py", + srcs = ["servicers.py"], + deps = [ + ":account_api_py", + ":account_py_reboot", + ":bank_api_py", + ":bank_py_reboot", + ], +) + +py_test( + name = "test_py", + srcs = ["test.py"], + main = "test.py", + deps = [ + ":servicers_py", + "//reboot/aio:applications_py", + "//reboot/aio:tests_py", + ], +) diff --git a/tests/reboot/pydantic/shared_error/account_api.py b/tests/reboot/pydantic/shared_error/account_api.py new file mode 100644 index 000000000..8d670be38 --- /dev/null +++ b/tests/reboot/pydantic/shared_error/account_api.py @@ -0,0 +1,60 @@ +"""An account with a balance a withdrawal may not take below zero.""" +from reboot.api import API, Field, Methods, Model, Reader, Type, Writer + + +class AccountState(Model): + balance: float = Field(tag=1) + + +class OpenRequest(Model): + balance: float = Field(tag=1) + + +class DepositRequest(Model): + amount: float = Field(tag=1) + + +class WithdrawRequest(Model): + amount: float = Field(tag=1) + + +class BalanceResponse(Model): + amount: float = Field(tag=1) + + +class OverdraftError(Model): + """By how much a withdrawal exceeded the balance.""" + amount: float = Field(tag=1) + + +AccountMethods = Methods( + open=Writer( + request=OpenRequest, + response=None, + factory=True, + mcp=None, + ), + deposit=Writer( + request=DepositRequest, + response=None, + mcp=None, + ), + withdraw=Writer( + request=WithdrawRequest, + response=None, + errors=[OverdraftError], + mcp=None, + ), + balance=Reader( + request=None, + response=BalanceResponse, + mcp=None, + ), +) + +api = API( + Account=Type( + state=AccountState, + methods=AccountMethods, + ), +) diff --git a/tests/reboot/pydantic/shared_error/bank_api.py b/tests/reboot/pydantic/shared_error/bank_api.py new file mode 100644 index 000000000..c0e9e733a --- /dev/null +++ b/tests/reboot/pydantic/shared_error/bank_api.py @@ -0,0 +1,37 @@ +"""A bank whose `transfer` declares an error another API file +defines: the account's `OverdraftError`.""" +from reboot.api import API, Field, Methods, Model, Transaction, Type +from tests.reboot.pydantic.shared_error.account_api import OverdraftError + + +class BankState(Model): + transfers: int = Field(tag=1, default=0) + + +class TransferRequest(Model): + from_account_id: str = Field(tag=1) + to_account_id: str = Field(tag=2) + amount: float = Field(tag=3) + + +BankMethods = Methods( + create=Transaction( + request=None, + response=None, + factory=True, + mcp=None, + ), + transfer=Transaction( + request=TransferRequest, + response=None, + errors=[OverdraftError], + mcp=None, + ), +) + +api = API( + Bank=Type( + state=BankState, + methods=BankMethods, + ), +) diff --git a/tests/reboot/pydantic/shared_error/servicers.py b/tests/reboot/pydantic/shared_error/servicers.py new file mode 100644 index 000000000..7f5ab6e15 --- /dev/null +++ b/tests/reboot/pydantic/shared_error/servicers.py @@ -0,0 +1,82 @@ +from reboot.aio.auth.authorizers import allow +from reboot.aio.contexts import ( + ReaderContext, + TransactionContext, + WriterContext, +) +from tests.reboot.pydantic.shared_error.account_api import ( + BalanceResponse, + DepositRequest, + OpenRequest, + OverdraftError, + WithdrawRequest, +) +from tests.reboot.pydantic.shared_error.account_api_rbt import Account +from tests.reboot.pydantic.shared_error.bank_api import TransferRequest +from tests.reboot.pydantic.shared_error.bank_api_rbt import Bank + + +class AccountServicer(Account.Servicer): + + def authorizer(self): + return allow() + + async def open( + self, + context: WriterContext, + request: OpenRequest, + ) -> None: + self.state.balance = request.balance + + async def deposit( + self, + context: WriterContext, + request: DepositRequest, + ) -> None: + self.state.balance += request.amount + + async def withdraw( + self, + context: WriterContext, + request: WithdrawRequest, + ) -> None: + if request.amount > self.state.balance: + raise Account.WithdrawAborted( + OverdraftError(amount=request.amount - self.state.balance) + ) + self.state.balance -= request.amount + + async def balance( + self, + context: ReaderContext, + ) -> BalanceResponse: + return BalanceResponse(amount=self.state.balance) + + +class BankServicer(Bank.Servicer): + + def authorizer(self): + return allow() + + async def create( + self, + context: TransactionContext, + ) -> None: + self.state.transfers = 0 + + async def transfer( + self, + context: TransactionContext, + request: TransferRequest, + ) -> None: + # The withdrawal's `OverdraftError` propagates as this method's + # own abort, since `transfer` declares it. + await Account.ref(request.from_account_id).withdraw( + context, + amount=request.amount, + ) + await Account.ref(request.to_account_id).deposit( + context, + amount=request.amount, + ) + self.state.transfers += 1 diff --git a/tests/reboot/pydantic/shared_error/test.py b/tests/reboot/pydantic/shared_error/test.py new file mode 100644 index 000000000..ee6ba6188 --- /dev/null +++ b/tests/reboot/pydantic/shared_error/test.py @@ -0,0 +1,69 @@ +"""A method may declare an error another API file defines: the +message is generated once, in that file's proto, and the error +propagates from the nested call as the declaring method's own.""" +import unittest +from reboot.aio.applications import Application +from reboot.aio.tests import Reboot +from tests.reboot.pydantic.shared_error.account_api import OverdraftError +from tests.reboot.pydantic.shared_error.account_api_rbt import Account +from tests.reboot.pydantic.shared_error.bank_api_rbt import Bank +from tests.reboot.pydantic.shared_error.servicers import ( + AccountServicer, + BankServicer, +) + + +class SharedErrorTest(unittest.IsolatedAsyncioTestCase): + + async def asyncSetUp(self) -> None: + self.rbt = Reboot() + await self.rbt.start() + await self.rbt.up( + Application(servicers=[AccountServicer, BankServicer]), + ) + + async def asyncTearDown(self) -> None: + await self.rbt.stop() + + async def test_transfer_aborts_with_the_accounts_error(self) -> None: + context = self.rbt.create_external_context(name=self.id()) + + payer, _ = await Account.open(context, 'payer', balance=100.0) + payee, _ = await Account.open(context, 'payee', balance=0.0) + bank, _ = await Bank.create(context, 'bank') + + with self.assertRaises(Bank.TransferAborted) as aborted: + await bank.transfer( + context, + from_account_id=payer.state_id, + to_account_id=payee.state_id, + amount=250.0, + ) + + self.assertIsInstance(aborted.exception.error, OverdraftError) + self.assertEqual(aborted.exception.error.amount, 150.0) + + # The transaction rolled back: neither account changed. + self.assertEqual((await payer.balance(context)).amount, 100.0) + self.assertEqual((await payee.balance(context)).amount, 0.0) + + async def test_transfer_within_the_balance_moves_it(self) -> None: + context = self.rbt.create_external_context(name=self.id()) + + payer, _ = await Account.open(context, 'payer', balance=100.0) + payee, _ = await Account.open(context, 'payee', balance=0.0) + bank, _ = await Bank.create(context, 'bank') + + await bank.transfer( + context, + from_account_id=payer.state_id, + to_account_id=payee.state_id, + amount=40.0, + ) + + self.assertEqual((await payer.balance(context)).amount, 60.0) + self.assertEqual((await payee.balance(context)).amount, 40.0) + + +if __name__ == '__main__': + unittest.main(verbosity=2) From bca33a7d9c5dd3c92f9fee0987130d43f040c168 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Sat, 5 Sep 2026 19:16:20 +0000 Subject: [PATCH 32/42] Declare `OverdraftError` on bank-pydantic's `transfer` 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 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- reboot/examples/bank-pydantic/api/bank/v1/bank.py | 8 ++++++-- .../bank-pydantic/backend/tests/transfers.feature | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/reboot/examples/bank-pydantic/api/bank/v1/bank.py b/reboot/examples/bank-pydantic/api/bank/v1/bank.py index eaebb5b55..9e0236285 100644 --- a/reboot/examples/bank-pydantic/api/bank/v1/bank.py +++ b/reboot/examples/bank-pydantic/api/bank/v1/bank.py @@ -1,3 +1,4 @@ +from bank.v1.account import OverdraftError from reboot.api import ( API, UI, @@ -74,8 +75,11 @@ class AccountBalancesResponse(Model): transfer=Transaction( request=TransferRequest, response=None, - description="Transfer an amount between two accounts. " - "Get `from_account_id` and `to_account_id` from " + errors=[OverdraftError], + description="Transfer an amount between two accounts; fails " + "with an overdraft error, leaving both accounts unchanged, if " + "the balance of `from_account_id` is insufficient. Get " + "`from_account_id` and `to_account_id` from " "`bank_account_balances`.", mcp=Tool(), ), diff --git a/reboot/examples/bank-pydantic/backend/tests/transfers.feature b/reboot/examples/bank-pydantic/backend/tests/transfers.feature index ea6818b96..8616191af 100644 --- a/reboot/examples/bank-pydantic/backend/tests/transfers.feature +++ b/reboot/examples/bank-pydantic/backend/tests/transfers.feature @@ -39,6 +39,6 @@ Feature: Transferring money between accounts And the `Customer` for "payee@reboot.dev" gets an `open_account` with `initial_deposit=0.0` And the resulting `account_id` is saved as `payee_account_id` And the `Bank` for "test-bank" attempts a `transfer` with `from_account_id=` and `to_account_id=` and `amount=250.0` - Then the attempt aborts with `Unknown` + Then the attempt aborts with `OverdraftError` with `amount=150.0` And `balance` on the `Account` for "" has `amount=100.0` And `balance` on the `Account` for "" has `amount=0.0` From 97a6f361355fd79734b890867e8c4357cebb7981 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Sun, 6 Sep 2026 16:04:58 +0000 Subject: [PATCH 33/42] Name who makes each call in `reboot.bdd` scenarios A scenario used to carry a current user: 'the authenticated user is "alice"' set it, 'the user is unauthenticated' cleared it, and every call from then on ran as whoever was set last. Reading a scenario meant scrolling up to find out who a step ran as, and two users in one scenario meant switching the current user back and forth. A scenario now declares its people, '"alice" is an authenticated user', which mints a token for that user id, or '"admin" has the bearer token "..."', which names a user by a token obtained some other way, and every step that calls says who: 'as "alice"' at its start. A step without it calls anonymously, so there is no current user to set and no step to say a call is unauthenticated. 'a shared context' takes the same prefix and every call through it must name the same user, since the context keeps the token it was created with. The `World` keeps a token per declared user, and `context()`, `call()` and `spawn()` take the user; a custom step passes the user its text names, or nothing. The grammar's typed syntax carries the user on each calling step, and the Behaviors page prints it as a user span at the start of the step. The old spellings are near-miss steps saying what to write instead. The examples' feature files are rewritten: those whose authorizers allow everyone lose their unauthenticated line and change nothing else, and those with real authorization name their users on each call, the swag store's admin token becoming a user named "admin". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- rbt/v1alpha1/bdd/grammar.proto | 80 +++++++---- reboot/bdd/fixtures.py | 106 +++++++++------ reboot/bdd/grammar.py | 60 +++++---- reboot/bdd/steps.py | 125 ++++++++++++------ reboot/dashboard/web/src/behaviors.ts | 36 +++-- .../backend/tests/wiki_crud.feature | 30 ++--- .../backend/tests/wiki_ingest.feature | 12 +- .../backend/tests/wiki_transcript.feature | 8 +- .../backend/tests/deposits.feature | 1 - .../backend/tests/transfers.feature | 1 - .../backend/tests/withdrawals.feature | 1 - .../chat-room/backend/tests/chat_room.feature | 1 - .../chick-potle/backend/tests/food.feature | 42 +++--- .../chick-potle/backend/tests/food_test.py | 2 +- .../bank/backend/tests/account.feature | 1 - .../monorepo/bank/backend/tests/bank.feature | 1 - .../backend/tests/hello.feature | 1 - .../hello-tasks/backend/tests/hello.feature | 1 - .../backend/tests/store.feature | 95 +++++++------ tests/reboot/bdd/accounts.feature | 19 ++- tests/reboot/bdd/bdd_tests.py | 37 +++--- tests/reboot/bdd/collisions.feature | 1 - tests/reboot/bdd/grammar_tests.py | 52 +++++++- tests/reboot/bdd/pydantic/accounts.feature | 13 +- tests/reboot/bdd/variation.feature | 2 - 25 files changed, 438 insertions(+), 290 deletions(-) diff --git a/rbt/v1alpha1/bdd/grammar.proto b/rbt/v1alpha1/bdd/grammar.proto index 9cfe88863..8939a9310 100644 --- a/rbt/v1alpha1/bdd/grammar.proto +++ b/rbt/v1alpha1/bdd/grammar.proto @@ -83,21 +83,26 @@ message ApplicationIsUp { optional string name = 1; } -// 'the authenticated user is "user_id"'. -message AuthenticatedUserIs { +// '"alice" is an authenticated user': declares a user the scenario's +// steps may call as, minting a token for the user id. +message IsAnAuthenticatedUser { string user_id = 1; } -// 'the user is unauthenticated'. -message UserIsUnauthenticated {} +// '"admin" has the bearer token "..."': declares a user by a token +// the scenario obtained some other way. +message HasBearerToken { + string user_id = 1; -// 'the bearer token is "bearer_token"'. -message BearerTokenIs { - string bearer_token = 1; + string bearer_token = 2; } -// 'a shared context'. -message SharedContext {} +// 'as "alice" a shared context'. +message SharedContext { + // The user the shared context's calls are made as; absent for + // anonymous calls. + optional string user = 1; +} // 'a `Account` for "alice" gets created via `open` with ...'. message GetsCreatedVia { @@ -106,10 +111,13 @@ message GetsCreatedVia { string method = 2; repeated Assignment assignments = 3; + + // The user the call is made as; absent for an anonymous call. + optional string user = 4; } -// 'the `Account` for "alice" gets a `deposit` with ...', and -// optionally 'spawned with its task id saved as `name`'. +// 'as "alice" the `Account` for "alice" gets a `deposit` with ...', +// and optionally 'spawned with its task id saved as `name`'. message Gets { State state = 1; @@ -120,6 +128,9 @@ message Gets { // The name the spawned task's id is saved under; absent for a // call that is not spawned. optional string task_id_saved_as = 4; + + // The user the call is made as; absent for an anonymous call. + optional string user = 5; } // 'the `Account` for "alice" attempts a `withdraw` with ...'. @@ -129,6 +140,9 @@ message Attempts { string method = 2; repeated Assignment assignments = 3; + + // The user the call is made as; absent for an anonymous call. + optional string user = 4; } // 'the `deposit` task with id "" of the `Account` completes @@ -142,6 +156,9 @@ message TaskCompletes { string state_type = 3; double seconds = 4; + + // The user the call is made as; absent for an anonymous call. + optional string user = 5; } // 'the attempt aborts with `OverdraftError` with ...'. @@ -160,6 +177,9 @@ message Has { State state = 2; repeated Assertion assertions = 3; + + // The user the call is made as; absent for an anonymous call. + optional string user = 4; } // '`balance` on the `Account` for "alice" eventually has ... within @@ -173,6 +193,9 @@ message EventuallyHas { repeated Assertion assertions = 3; double seconds = 4; + + // The user the call is made as; absent for an anonymous call. + optional string user = 5; } // '`get` on the `Account` for "alice" has `owner` saved as `o`': a @@ -183,6 +206,9 @@ message HasSavedAs { State state = 2; repeated Save saves = 3; + + // The user the call is made as; absent for an anonymous call. + optional string user = 4; } // '`balance` on the `Account` for "alice" aborts with @@ -197,6 +223,9 @@ message AbortsWith { string error_type = 3; repeated Assertion assertions = 4; + + // The user the call is made as; absent for an anonymous call. + optional string user = 5; } // 'the result has ...'. @@ -216,20 +245,19 @@ message ResultingIsSavedAs { message BuiltInSyntax { oneof step { ApplicationIsUp application_is_up = 1; - AuthenticatedUserIs authenticated_user_is = 2; - UserIsUnauthenticated user_is_unauthenticated = 3; - BearerTokenIs bearer_token_is = 4; - SharedContext shared_context = 5; - GetsCreatedVia gets_created_via = 6; - Gets gets = 7; - Attempts attempts = 8; - TaskCompletes task_completes = 9; - AttemptAbortsWith attempt_aborts_with = 10; - Has has = 11; - EventuallyHas eventually_has = 12; - HasSavedAs has_saved_as = 13; - AbortsWith aborts_with = 14; - ResultHas result_has = 15; - ResultingIsSavedAs resulting_is_saved_as = 16; + IsAnAuthenticatedUser is_an_authenticated_user = 2; + HasBearerToken has_bearer_token = 3; + SharedContext shared_context = 4; + GetsCreatedVia gets_created_via = 5; + Gets gets = 6; + Attempts attempts = 7; + TaskCompletes task_completes = 8; + AttemptAbortsWith attempt_aborts_with = 9; + Has has = 10; + EventuallyHas eventually_has = 11; + HasSavedAs has_saved_as = 12; + AbortsWith aborts_with = 13; + ResultHas result_has = 14; + ResultingIsSavedAs resulting_is_saved_as = 15; } } diff --git a/reboot/bdd/fixtures.py b/reboot/bdd/fixtures.py index 851e16c14..a3e0b21c1 100644 --- a/reboot/bdd/fixtures.py +++ b/reboot/bdd/fixtures.py @@ -165,37 +165,69 @@ class World: # or `None` if that call succeeded. aborted: Optional[Aborted] = None - # The bearer token every context created from here on carries; - # `None` calls unauthenticated. - bearer_token: Optional[str] = None - - # Whether the scenario has said who calls, authenticated or - # not; every call requires it. - user_declared: bool = False - - def context(self) -> ExternalContext: - """The context for one step's call: the scenario's shared - context once a 'Given a shared context' step has created it, - otherwise a fresh context.""" + # The bearer token of each user the scenario has declared, by + # user id; a step says 'as "user id"' to call as one. + tokens: dict[str, str] = field(default_factory=dict) + + # The user the shared context calls as, once one exists; `None` + # for one that calls anonymously. + shared_user: Optional[str] = None + + def context(self, user: Optional[str] = None) -> ExternalContext: + """The context for one step's call as the given user, or + anonymously for `None`: the scenario's shared context once a + 'Given a shared context' step has created it, which must be + for the same user, otherwise a fresh context.""" if self.shared_context is not None: + if user != self.shared_user: + raise ValueError( + "The shared context calls as " + + self._user_description(self.shared_user) + + ", so this step cannot call as " + + self._user_description(user) + ) return self.shared_context if self.rbt is None: raise ValueError( "The application is not up; start the scenario with " "'Given the application is up'" ) - if not self.user_declared: - raise ValueError( - "The scenario has not declared a user; say 'Given " - 'the authenticated user is "..."\' or \'Given the ' - "user is unauthenticated'" - ) self.contexts_created += 1 return self.rbt.create_external_context( name=f"{self.name}-{self.contexts_created}", - bearer_token=self.bearer_token, + bearer_token=self.token(user), ) + @staticmethod + def _user_description(user: Optional[str]) -> str: + return 'nobody' if user is None else f'"{user}"' + + def token(self, user: Optional[str]) -> Optional[str]: + """The bearer token of the named user, `None` for the + anonymous user; raises for a user the scenario has not + declared.""" + if user is None: + return None + token = self.tokens.get(user) + if token is None: + raise ValueError( + f'"{user}" is not a user the scenario has declared; say ' + f'\'Given "{user}" is an authenticated user\' or ' + f'\'Given "{user}" has the bearer token "..."\' first' + ) + return token + + def declare_user(self, user_id: str, bearer_token: str) -> None: + """Declares a user the scenario's steps may call as, by the + bearer token their calls carry.""" + self.tokens[user_id] = bearer_token + + def share_context(self, user: Optional[str]) -> None: + """Makes every call from here on share one context, calling + as the given user, or anonymously for `None`.""" + self.shared_context = self.context(user) + self.shared_user = user + def save(self, name: str, value: JsonValue) -> None: """Saves the value under the name, for later steps to say '' to use; raises for a name that is a column of the @@ -208,20 +240,6 @@ def save(self, name: str, value: JsonValue) -> None: ) self.saved[name] = value - def set_bearer_token(self, bearer_token: Optional[str]) -> None: - """Sets the bearer token every context created from here on - carries, `None` for unauthenticated, satisfying the say-who- - calls requirement either way; raises once a shared context - exists, which keeps the token it was created with.""" - if self.shared_context is not None: - raise ValueError( - "The shared context already carries an identity; say " - "who the authenticated user is before 'Given a " - "shared context'" - ) - self.bearer_token = bearer_token - self.user_declared = True - def client_type(self, state_type: str) -> Any: """The generated client class of the named state type, named by its full state type name (e.g. 'bank.v1.Account') or, when @@ -314,18 +332,20 @@ async def spawn( state_id: str, method: str, assignments: Union[dict[str, JsonValue], list[Assignment]], + user: Optional[str] = None, ) -> Any: - """Spawns the named method as a task on the named state, - using the specified `assignments` to create a request, and - returns the task handle to await for its response.""" + """Spawns the named method as a task on the named state, as + the given user or anonymously, using the specified + `assignments` to create a request, and returns the task + handle to await for its response.""" reference = self.client_type(state_type).ref(state_id) spawn = getattr(reference.spawn(), method, None) if not callable(spawn): raise ValueError(f"`{state_type}` has no method `{method}`") if not assignments: - return await spawn(self.context()) + return await spawn(self.context(user)) return await spawn( - self.context(), + self.context(user), self.request( state_type=state_type, method=method, @@ -483,17 +503,19 @@ async def call( state_id: str, method: str, assignments: Union[dict[str, JsonValue], list[Assignment]], + user: Optional[str] = None, ) -> Any: - """Returns the response from calling the named method on the named - state using the specified `assignments` to create a request.""" + """Returns the response from calling the named method on the + named state, as the given user or anonymously, using the + specified `assignments` to create a request.""" reference = self.client_type(state_type).ref(state_id) method_callable = getattr(reference, method, None) if not callable(method_callable): raise ValueError(f"`{state_type}` has no method `{method}`") if not assignments: - return await method_callable(self.context()) + return await method_callable(self.context(user)) return await method_callable( - self.context(), + self.context(user), self.request( state_type=state_type, method=method, diff --git a/reboot/bdd/grammar.py b/reboot/bdd/grammar.py index 24c87ce08..9a9c446a0 100644 --- a/reboot/bdd/grammar.py +++ b/reboot/bdd/grammar.py @@ -16,8 +16,6 @@ Assignment, AttemptAbortsWith, Attempts, - AuthenticatedUserIs, - BearerTokenIs, BuiltInSyntax, Containing, Equals, @@ -25,7 +23,9 @@ Gets, GetsCreatedVia, Has, + HasBearerToken, HasSavedAs, + IsAnAuthenticatedUser, OfLength, ResultHas, ResultingIsSavedAs, @@ -33,7 +33,6 @@ SharedContext, State, TaskCompletes, - UserIsUnauthenticated, Value, ) from typing import Optional @@ -120,6 +119,10 @@ # on. STATE = r'the `(?P[\w.]+)` for "(?P[^"]*)"' +# The user a step calls as, 'as "alice" ' at the step's start; a step +# without it calls anonymously. +AS = r'(?:as "(?P[^"]*)" )?' + # A step's optional trailing property list. PROPERTIES = rf'(?: with (?P{PROPERTY_CLAUSES}))?' @@ -127,37 +130,38 @@ # with pytest-bdd, and what `read` reads a step by. Named for the # phrase that distinguishes the step. APPLICATION_IS_UP = r'the (?:"(?P[^"]*)" )?application is up$' -AUTHENTICATED_USER_IS = r'the authenticated user is "(?P[^"]*)"$' -USER_IS_UNAUTHENTICATED = 'the user is unauthenticated' -BEARER_TOKEN_IS = r'the bearer token is "(?P[^"]*)"$' -SHARED_CONTEXT = 'a shared context' +IS_AN_AUTHENTICATED_USER = r'"(?P[^"]*)" is an authenticated user$' +HAS_BEARER_TOKEN = ( + r'"(?P[^"]*)" has the bearer token "(?P[^"]*)"$' +) +SHARED_CONTEXT = rf'{AS}a shared context$' GETS_CREATED_VIA = ( - r'(?:a|an) `(?P[\w.]+)` for "(?P[^"]*)" ' + rf'{AS}(?:a|an) `(?P[\w.]+)` for "(?P[^"]*)" ' rf'gets created via `(?P\w+)`{PROPERTIES}$' ) GETS = ( - rf'{STATE} gets (?:a|an) `(?P\w+)`{PROPERTIES}' + rf'{AS}{STATE} gets (?:a|an) `(?P\w+)`{PROPERTIES}' r'(?: spawned with its task id saved as `(?P\w+)`)?$' ) -ATTEMPTS = rf'{STATE} attempts (?:a|an) `(?P\w+)`{PROPERTIES}$' +ATTEMPTS = rf'{AS}{STATE} attempts (?:a|an) `(?P\w+)`{PROPERTIES}$' TASK_COMPLETES = ( - r'the `(?P\w+)` task with id "<(?P\w+)>" ' + rf'{AS}the `(?P\w+)` task with id "<(?P\w+)>" ' r'of the `(?P[\w.]+)` completes within (?P.+)$' ) ATTEMPT_ABORTS_WITH = ( r'the attempt aborts with `(?P\w+)`' rf'(?: with (?P{ASSERT_CLAUSES}))?$' ) -HAS = rf'`(?P\w+)` on {STATE} has (?P{ASSERT_CLAUSES})$' +HAS = rf'{AS}`(?P\w+)` on {STATE} has (?P{ASSERT_CLAUSES})$' EVENTUALLY_HAS = ( - rf'`(?P\w+)` on {STATE} ' + rf'{AS}`(?P\w+)` on {STATE} ' rf'eventually has (?P{ASSERT_CLAUSES}) within (?P.+)$' ) HAS_SAVED_AS = ( - rf'`(?P\w+)` on {STATE} has (?P{SAVE_CLAUSES})$' + rf'{AS}`(?P\w+)` on {STATE} has (?P{SAVE_CLAUSES})$' ) ABORTS_WITH = ( - rf'`(?P\w+)` on {STATE} aborts with `(?P\w+)`' + rf'{AS}`(?P\w+)` on {STATE} aborts with `(?P\w+)`' rf'(?: with (?P{ASSERT_CLAUSES}))?$' ) RESULT_HAS = rf'the result has (?P{ASSERT_CLAUSES})$' @@ -265,22 +269,24 @@ def parse(text: str) -> Optional[BuiltInSyntax]: if match['name'] is not None: application_is_up.name = match['name'] return BuiltInSyntax(application_is_up=application_is_up) - match = re.match(AUTHENTICATED_USER_IS, text) + match = re.match(IS_AN_AUTHENTICATED_USER, text) if match is not None: return BuiltInSyntax( - authenticated_user_is=AuthenticatedUserIs( + is_an_authenticated_user=IsAnAuthenticatedUser( user_id=match['user_id'] ) ) - if text == USER_IS_UNAUTHENTICATED: - return BuiltInSyntax(user_is_unauthenticated=UserIsUnauthenticated()) - match = re.match(BEARER_TOKEN_IS, text) + match = re.match(HAS_BEARER_TOKEN, text) if match is not None: return BuiltInSyntax( - bearer_token_is=BearerTokenIs(bearer_token=match['bearer_token']) + has_bearer_token=HasBearerToken( + user_id=match['user_id'], + bearer_token=match['bearer_token'], + ) ) - if text == SHARED_CONTEXT: - return BuiltInSyntax(shared_context=SharedContext()) + match = re.match(SHARED_CONTEXT, text) + if match is not None: + return BuiltInSyntax(shared_context=SharedContext(user=match['user'])) match = re.match(GETS_CREATED_VIA, text) if match is not None: return BuiltInSyntax( @@ -288,6 +294,7 @@ def parse(text: str) -> Optional[BuiltInSyntax]: state=_state(match), method=match['method'], assignments=_assignments(match['clauses']), + user=match['user'], ) ) match = re.match(GETS, text) @@ -296,6 +303,7 @@ def parse(text: str) -> Optional[BuiltInSyntax]: state=_state(match), method=match['method'], assignments=_assignments(match['clauses']), + user=match['user'], ) if match['task'] is not None: gets.task_id_saved_as = match['task'] @@ -307,6 +315,7 @@ def parse(text: str) -> Optional[BuiltInSyntax]: state=_state(match), method=match['method'], assignments=_assignments(match['clauses']), + user=match['user'], ) ) match = re.match(TASK_COMPLETES, text) @@ -320,6 +329,7 @@ def parse(text: str) -> Optional[BuiltInSyntax]: task_id_saved_as=match['name'], state_type=match['state_type'], seconds=seconds, + user=match['user'], ) ) match = re.match(ATTEMPT_ABORTS_WITH, text) @@ -337,6 +347,7 @@ def parse(text: str) -> Optional[BuiltInSyntax]: method=match['method'], state=_state(match), assertions=_assertions(match['clauses']), + user=match['user'], ) ) match = re.match(EVENTUALLY_HAS, text) @@ -350,6 +361,7 @@ def parse(text: str) -> Optional[BuiltInSyntax]: state=_state(match), assertions=_assertions(match['clauses']), seconds=seconds, + user=match['user'], ) ) match = re.match(HAS_SAVED_AS, text) @@ -359,6 +371,7 @@ def parse(text: str) -> Optional[BuiltInSyntax]: method=match['method'], state=_state(match), saves=_saves(match['clauses']), + user=match['user'], ) ) match = re.match(ABORTS_WITH, text) @@ -369,6 +382,7 @@ def parse(text: str) -> Optional[BuiltInSyntax]: state=_state(match), error_type=match['error_type'], assertions=_assertions(match['clauses']), + user=match['user'], ) ) match = re.match(RESULT_HAS, text) diff --git a/reboot/bdd/steps.py b/reboot/bdd/steps.py index 2ddf23d2f..eb83015d1 100644 --- a/reboot/bdd/steps.py +++ b/reboot/bdd/steps.py @@ -40,13 +40,14 @@ def application() -> Application: Then `balance` on the `Account` for "alice" has `balance=50` -Every scenario says who calls before its first call: 'Given the -authenticated user is "alice"' mints a test token for that user ID -and puts it on every context created from then on, 'Given the user -is unauthenticated' calls with no token, and 'the bearer token is -"..."' instead sets a raw token; say who calls before 'Given a -shared context', whose context keeps the token it was created -with. +Every step that calls says who calls, or calls anonymously: a step +starting 'as "alice"' calls as a user the scenario declared, with +'Given "alice" is an authenticated user', which mints a test token +for that user ID, or 'Given "admin" has the bearer token "..."', +which names a user by a raw token; a step without 'as "..."' calls +with no token. 'Given a shared context' takes the same prefix, and +every call from then on must name the same user, since the context +keeps the token it was created with. A call runs as a task instead by saying 'gets a `method` ... spawned with its task id saved as `name`'; the task then awaits as @@ -124,15 +125,15 @@ def application() -> Application: ASSERT_CLAUSES, ATTEMPT_ABORTS_WITH, ATTEMPTS, - AUTHENTICATED_USER_IS, - BEARER_TOKEN_IS, CLAUSE, CONTAINING_PATTERN, EVENTUALLY_HAS, GETS, GETS_CREATED_VIA, HAS, + HAS_BEARER_TOKEN, HAS_SAVED_AS, + IS_AN_AUTHENTICATED_USER, LENGTH_PATTERN, MIXED_CLAUSES, PATH, @@ -146,7 +147,6 @@ def application() -> Application: SEPARATOR, SHARED_CONTEXT, TASK_COMPLETES, - USER_IS_UNAUTHENTICATED, ) from reboot.bdd.registry import client_types_by_name from typing import Any, Optional, Union, get_args, get_origin @@ -775,42 +775,40 @@ async def _the_application_is_up( world.name = request.node.name -@given(parsers.re(AUTHENTICATED_USER_IS)) -@when(parsers.re(AUTHENTICATED_USER_IS)) -async def _the_authenticated_user_is(world: World, user_id: str) -> None: +@given(parsers.re(IS_AN_AUTHENTICATED_USER)) +@when(parsers.re(IS_AN_AUTHENTICATED_USER)) +async def _is_an_authenticated_user(world: World, user_id: str) -> None: if world.rbt is None: raise ValueError( "The application is not up; start the scenario with " "'Given the application is up'" ) - world.set_bearer_token( - await world.rbt.make_valid_oauth_access_token( - user_id=_maybe_saved(world, user_id), - ) + user_id = _maybe_saved(world, user_id) + world.declare_user( + user_id, + await world.rbt.make_valid_oauth_access_token(user_id=user_id), ) -@given(USER_IS_UNAUTHENTICATED) -@when(USER_IS_UNAUTHENTICATED) -def _the_user_is_unauthenticated(world: World) -> None: - world.set_bearer_token(None) - - -@given(parsers.re(BEARER_TOKEN_IS)) -@when(parsers.re(BEARER_TOKEN_IS)) -def _the_bearer_token_is(world: World, bearer_token: str) -> None: - world.set_bearer_token(_maybe_saved(world, bearer_token)) +@given(parsers.re(HAS_BEARER_TOKEN)) +@when(parsers.re(HAS_BEARER_TOKEN)) +def _has_bearer_token(world: World, user_id: str, bearer_token: str) -> None: + world.declare_user( + _maybe_saved(world, user_id), + _maybe_saved(world, bearer_token), + ) -@given(SHARED_CONTEXT) -def _a_shared_context(world: World) -> None: - world.shared_context = world.context() +@given(parsers.re(SHARED_CONTEXT)) +def _a_shared_context(world: World, user: Optional[str]) -> None: + world.share_context(user) @given(parsers.re(GETS_CREATED_VIA)) @when(parsers.re(GETS_CREATED_VIA)) async def _gets_created_via( world: World, + user: Optional[str], state_type: str, state_id: str, method: str, @@ -818,7 +816,7 @@ async def _gets_created_via( ) -> None: factory = world.factory(state_type=state_type, method=method) assignments = _parse_assignments(world, clauses) - arguments = [world.context(), _maybe_saved(world, state_id)] + arguments = [world.context(user), _maybe_saved(world, state_id)] if assignments: arguments.append( world.request( @@ -838,6 +836,7 @@ async def _gets_created_via( @when(parsers.re(GETS)) async def _gets( world: World, + user: Optional[str], state_type: str, state_id: str, method: str, @@ -850,6 +849,7 @@ async def _gets( state_id=_maybe_saved(world, state_id), method=method, assignments=_parse_assignments(world, clauses), + user=user, ) world.save(task, _json_object(handle.task_id)) return @@ -864,6 +864,7 @@ async def _gets( state_id=_maybe_saved(world, state_id), method=method, assignments=_parse_assignments(world, clauses), + user=user, ) except Aborted as aborted: raise AssertionError( @@ -877,6 +878,7 @@ async def _gets( @when(parsers.re(ATTEMPTS)) async def _attempts( world: World, + user: Optional[str], state_type: str, state_id: str, method: str, @@ -894,6 +896,7 @@ async def _attempts( state_id=_maybe_saved(world, state_id), method=method, assignments=_parse_assignments(world, clauses), + user=user, ) world.aborted = None except Aborted as aborted: @@ -904,6 +907,7 @@ async def _attempts( @then(parsers.re(TASK_COMPLETES)) async def _the_saved_task_completes( world: World, + user: Optional[str], method: str, name: str, state_type: str, @@ -920,7 +924,7 @@ async def _the_saved_task_completes( if task_type is None: raise ValueError(f"`{state_type}` has no `{method}` task") task = getattr(task_type, 'retrieve')( - world.context(), + world.context(user), task_id=json_format.ParseDict(saved, tasks_pb2.TaskId()), ) try: @@ -963,12 +967,14 @@ def _the_attempt_aborts_with( async def _read( world: World, + user: Optional[str], method: str, state_type: str, state_id: str, ) -> Any: - """Calls the named reader on the named state, recording and - returning its response; raises if the method is not a reader.""" + """Calls the named reader on the named state as the given user, + or anonymously, recording and returning its response; raises if + the method is not a reader.""" if not world.is_reader(state_type=state_type, method=method): raise ValueError( f"`{method}` is not a reader; call it with 'the " @@ -980,6 +986,7 @@ async def _read( state_id=_maybe_saved(world, state_id), method=method, assignments={}, + user=user, ) return world.response except Aborted as aborted: @@ -992,18 +999,20 @@ async def _read( @then(parsers.re(HAS)) async def _then_has( world: World, + user: Optional[str], method: str, state_type: str, state_id: str, clauses: str, ) -> None: - response = await _read(world, method, state_type, state_id) + response = await _read(world, user, method, state_type, state_id) _assert_properties(response, _parse_assertions(world, clauses)) @then(parsers.re(EVENTUALLY_HAS)) async def _eventually_has( world: World, + user: Optional[str], method: str, state_type: str, state_id: str, @@ -1020,7 +1029,7 @@ async def _eventually_has( reference = world.client_type(state_type).ref( _maybe_saved(world, state_id) ) - responses = getattr(reference.reactively(), method)(world.context()) + responses = getattr(reference.reactively(), method)(world.context(user)) deadline = asyncio.get_running_loop().time() + seconds last_error: Optional[AssertionError] = None try: @@ -1064,12 +1073,13 @@ async def _eventually_has( @when(parsers.re(HAS_SAVED_AS)) async def _has_saved_as( world: World, + user: Optional[str], method: str, state_type: str, state_id: str, clauses: str, ) -> None: - response = await _read(world, method, state_type, state_id) + response = await _read(world, user, method, state_type, state_id) response_json = _json_object(response) for name, path in _parse_saves(clauses).items(): world.save(name, _resolve_json_property(response_json, path)) @@ -1078,6 +1088,7 @@ async def _has_saved_as( @then(parsers.re(ABORTS_WITH)) async def _aborts_with( world: World, + user: Optional[str], method: str, state_type: str, state_id: str, @@ -1096,6 +1107,7 @@ async def _aborts_with( state_id=_maybe_saved(world, state_id), method=method, assignments={}, + user=user, ) except Aborted as aborted: _assert_aborted(world, aborted, error_type, clauses) @@ -1179,13 +1191,38 @@ def _almost_unquoted_application() -> None: @given(parsers.re(r'I am "[^"]*"$')) @when(parsers.re(r'I am "[^"]*"$')) def _almost_i_am() -> None: - raise ValueError("Almost: say 'the authenticated user is \"...\"'") + raise ValueError( + "Almost: say '\"...\" is an authenticated user', then start each " + "step that calls as them with 'as \"...\"'" + ) + + +@given(parsers.re(r'the authenticated user is "[^"]*"$')) +@when(parsers.re(r'the authenticated user is "[^"]*"$')) +def _almost_the_authenticated_user_is() -> None: + raise ValueError( + "Almost: say '\"...\" is an authenticated user', then start each " + "step that calls as them with 'as \"...\"'; a step without " + "'as \"...\"' calls anonymously" + ) + + +@given(parsers.re(r'the bearer token is "[^"]*"$')) +@when(parsers.re(r'the bearer token is "[^"]*"$')) +def _almost_the_bearer_token_is() -> None: + raise ValueError( + "Almost: say '\"...\" has the bearer token \"...\"', naming the " + "user, then start each step that calls as them with 'as \"...\"'" + ) -@given('the user is anonymous') -@when('the user is anonymous') +@given(parsers.re(r'the user is (?:anonymous|unauthenticated)$')) +@when(parsers.re(r'the user is (?:anonymous|unauthenticated)$')) def _almost_anonymous() -> None: - raise ValueError("Almost: say 'the user is unauthenticated'") + raise ValueError( + "Almost: no step declares that; a step without 'as \"...\"' " + "calls anonymously" + ) @given(parsers.re(r'.+ eventually has .+$')) @@ -1272,8 +1309,10 @@ def _almost_predicate_in_call_with() -> None: # A clause list with no backticks at all, and one whose backticks do # not pair up (a leading backtick followed by zero or more closed # pairs leaves one unclosed): every valid clause list pairs its -# backticks, so both shapes are disjoint from every step above. -_UNBACKTICKED_CLAUSES = r'[^`]+' +# backticks, so both shapes are disjoint from every step above. The +# one step whose 'has' is followed by no clauses, '"admin" has the +# bearer token "..."', is left out. +_UNBACKTICKED_CLAUSES = r'(?!the bearer token ")[^`]+' _UNCLOSED_CLAUSES = r'`[^`]*(?:`[^`]*`[^`]*)*' diff --git a/reboot/dashboard/web/src/behaviors.ts b/reboot/dashboard/web/src/behaviors.ts index 1b1e3667e..b5f7c01c7 100644 --- a/reboot/dashboard/web/src/behaviors.ts +++ b/reboot/dashboard/web/src/behaviors.ts @@ -177,6 +177,13 @@ const spansOfValue = (value: grammar_pb.Value | undefined): Span[] => const spansOfStateId = (id: string): Span[] => spansOfText(id, "state-id"); +// The 'as "alice"' a step starts with when it calls as a user; nothing +// for a step that calls anonymously. +const spansOfUser = (user: string | undefined): Span[] => + user === undefined + ? [] + : [text("as "), { text: `"${user}"`, role: "user" }, text(" ")]; + // 'the `Account` for "alice"', as the grammar's `STATE` phrase. const spansOfState = (state: grammar_pb.State | undefined): Span[] => [ text("the "), @@ -264,32 +271,31 @@ export const printBuiltInSyntax = ( clauses: [], tail: [], }; - case "authenticatedUserIs": + case "isAnAuthenticatedUser": return { head: [ - text("the authenticated user is "), { text: `"${step.value.userId}"`, role: "user" }, + text(" is an authenticated user"), ], clauses: [], tail: [], }; - case "userIsUnauthenticated": - return { - head: [text("the user is unauthenticated")], - clauses: [], - tail: [], - }; - case "bearerTokenIs": + case "hasBearerToken": return { head: [ - text("the bearer token is "), + { text: `"${step.value.userId}"`, role: "user" }, + text(" has the bearer token "), { text: `"${step.value.bearerToken}"`, role: "value" }, ], clauses: [], tail: [], }; case "sharedContext": - return { head: [text("a shared context")], clauses: [], tail: [] }; + return { + head: [...spansOfUser(step.value.user), text("a shared context")], + clauses: [], + tail: [], + }; case "getsCreatedVia": { const state = step.value.state; const article = articleOf(state?.type ?? ""); @@ -299,6 +305,7 @@ export const printBuiltInSyntax = ( ); return { head: [ + ...spansOfUser(step.value.user), text(article), { text: state?.type ?? "", role: "state-type" }, text(" for "), @@ -318,6 +325,7 @@ export const printBuiltInSyntax = ( ); return { head: [ + ...spansOfUser(step.value.user), ...spansOfState(step.value.state), text(` gets ${articleOf(step.value.method)}`), { text: step.value.method, role: "method" }, @@ -340,6 +348,7 @@ export const printBuiltInSyntax = ( ); return { head: [ + ...spansOfUser(step.value.user), ...spansOfState(step.value.state), text(` attempts ${articleOf(step.value.method)}`), { text: step.value.method, role: "method" }, @@ -352,6 +361,7 @@ export const printBuiltInSyntax = ( case "taskCompletes": return { head: [ + ...spansOfUser(step.value.user), text("the "), { text: step.value.method, role: "method" }, text(" task with id "), @@ -382,6 +392,7 @@ export const printBuiltInSyntax = ( case "has": return { head: [ + ...spansOfUser(step.value.user), { text: step.value.method, role: "method" }, text(" on "), ...spansOfState(step.value.state), @@ -393,6 +404,7 @@ export const printBuiltInSyntax = ( case "eventuallyHas": return { head: [ + ...spansOfUser(step.value.user), { text: step.value.method, role: "method" }, text(" on "), ...spansOfState(step.value.state), @@ -404,6 +416,7 @@ export const printBuiltInSyntax = ( case "hasSavedAs": return { head: [ + ...spansOfUser(step.value.user), { text: step.value.method, role: "method" }, text(" on "), ...spansOfState(step.value.state), @@ -419,6 +432,7 @@ export const printBuiltInSyntax = ( ); return { head: [ + ...spansOfUser(step.value.user), { text: step.value.method, role: "method" }, text(" on "), ...spansOfState(step.value.state), diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature b/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature index 22574bc68..6f8610aec 100644 --- a/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature +++ b/reboot/examples/agent-wiki/backend/tests/wiki_crud.feature @@ -2,28 +2,28 @@ Feature: Wiki, page, and transcript CRUD Background: Given the application is up - And the authenticated user is "alice" + And "alice" is an authenticated user Scenario: A created wiki appears in the user's list - When the `User` for "alice" gets a `create_wiki` with `name="my notes"` and `description="my personal notes"` + When as "alice" the `User` for "alice" gets a `create_wiki` with `name="my notes"` and `description="my personal notes"` And the resulting `wiki_id` is saved as `wiki_id` - Then `list_wikis` on the `User` for "alice" has `wikis` of length `1` and `wikis[0].wiki_id=` and `wikis[0].name="my notes"` and `wikis[0].description="my personal notes"` + Then as "alice" `list_wikis` on the `User` for "alice" has `wikis` of length `1` and `wikis[0].wiki_id=` and `wikis[0].name="my notes"` and `wikis[0].description="my personal notes"` Scenario: A fresh wiki updates its markdown body - Given the `User` for "alice" gets a `create_wiki` with `name="my notes"` and `description="my personal notes"` + Given as "alice" the `User` for "alice" gets a `create_wiki` with `name="my notes"` and `description="my personal notes"` And the resulting `wiki_id` is saved as `wiki_id` - Then `get` on the `Wiki` for "" has `name="my notes"` and `description="my personal notes"` and `content=""` - When the `Wiki` for "" gets a `update` with `content="# Hello\n"` - Then `get` on the `Wiki` for "" has `content="# Hello\n"` + Then as "alice" `get` on the `Wiki` for "" has `name="my notes"` and `description="my personal notes"` and `content=""` + When as "alice" the `Wiki` for "" gets a `update` with `content="# Hello\n"` + Then as "alice" `get` on the `Wiki` for "" has `content="# Hello\n"` Scenario: Pages round-trip their title and body - Given a `Page` for "my-page" gets created via `create` with `title="My Page"` and `content="Initial body."` and `owner_id="alice"` - Then `get` on the `Page` for "my-page" has `title="My Page"` and `content="Initial body."` - When the `Page` for "my-page" gets a `update` with `title="Renamed Page"` and `content="New body."` - Then `get` on the `Page` for "my-page" has `title="Renamed Page"` and `content="New body."` + Given as "alice" a `Page` for "my-page" gets created via `create` with `title="My Page"` and `content="Initial body."` and `owner_id="alice"` + Then as "alice" `get` on the `Page` for "my-page" has `title="My Page"` and `content="Initial body."` + When as "alice" the `Page` for "my-page" gets a `update` with `title="Renamed Page"` and `content="New body."` + Then as "alice" `get` on the `Page` for "my-page" has `title="Renamed Page"` and `content="New body."` Scenario: Transcripts round-trip their messages - Given a `Transcript` for "my-transcript" gets created via `create` with `messages=[{role: "user", content: "Hello"}, {role: "assistant", content: "Hi!"}]` and `owner_id="alice"` - Then `get` on the `Transcript` for "my-transcript" has `messages` of length `2` and `messages[0].role="user"` and `messages[0].content="Hello"` and `messages[1].role="assistant"` and `messages[1].content="Hi!"` - When the `Transcript` for "my-transcript" gets a `update` with `messages=[{role: "user", content: "Goodbye"}]` - Then `get` on the `Transcript` for "my-transcript" has `messages` of length `1` and `messages[0].content="Goodbye"` + Given as "alice" a `Transcript` for "my-transcript" gets created via `create` with `messages=[{role: "user", content: "Hello"}, {role: "assistant", content: "Hi!"}]` and `owner_id="alice"` + Then as "alice" `get` on the `Transcript` for "my-transcript" has `messages` of length `2` and `messages[0].role="user"` and `messages[0].content="Hello"` and `messages[1].role="assistant"` and `messages[1].content="Hi!"` + When as "alice" the `Transcript` for "my-transcript" gets a `update` with `messages=[{role: "user", content: "Goodbye"}]` + Then as "alice" `get` on the `Transcript` for "my-transcript" has `messages` of length `1` and `messages[0].content="Goodbye"` diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature b/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature index 38b53007c..f80d71df4 100644 --- a/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature +++ b/reboot/examples/agent-wiki/backend/tests/wiki_ingest.feature @@ -2,15 +2,15 @@ Feature: Ingesting transcripts through the librarian Background: Given the application is up - And the authenticated user is "alice" + And "alice" is an authenticated user Scenario: Adding a transcript wakes the librarian - Given the `User` for "alice" gets a `create_wiki` with `name="notes"` and `description="knowledge base"` + Given as "alice" the `User` for "alice" gets a `create_wiki` with `name="notes"` and `description="knowledge base"` And the resulting `wiki_id` is saved as `wiki_id` - When the `Wiki` for "" gets a `add_transcript` with `messages=[{role: "user", content: "Tell me about X."}, {role: "assistant", content: "X is a thing that does Y."}]` - Then `get` on the `Wiki` for "" eventually has `content` containing `"[Test Page](Page:"` within 30 seconds + When as "alice" the `Wiki` for "" gets a `add_transcript` with `messages=[{role: "user", content: "Tell me about X."}, {role: "assistant", content: "X is a thing that does Y."}]` + Then as "alice" `get` on the `Wiki` for "" eventually has `content` containing `"[Test Page](Page:"` within 30 seconds # The scripted librarian saves the moment its # `create_page` tool returns, which is before the wiki's content # updates, so once the line above passes the save exists. - And `get` on the `Wiki` for "" has `content` containing `` - And `get` on the `Page` for "" has `title="Test Page"` and `content="Distilled transcript content."` + And as "alice" `get` on the `Wiki` for "" has `content` containing `` + And as "alice" `get` on the `Page` for "" has `title="Test Page"` and `content="Distilled transcript content."` diff --git a/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature b/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature index 05124ec45..04c016c9f 100644 --- a/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature +++ b/reboot/examples/agent-wiki/backend/tests/wiki_transcript.feature @@ -2,11 +2,11 @@ Feature: Adding transcripts to a wiki Background: Given the application is up - And the authenticated user is "alice" + And "alice" is an authenticated user Scenario: Adding a transcript creates it - Given the `User` for "alice" gets a `create_wiki` with `name="notes"` and `description=""` + Given as "alice" the `User` for "alice" gets a `create_wiki` with `name="notes"` and `description=""` And the resulting `wiki_id` is saved as `wiki_id` - When the `Wiki` for "" gets a `add_transcript` with `messages=[{role: "user", content: "Hi."}, {role: "assistant", content: "Hello!"}]` + When as "alice" the `Wiki` for "" gets a `add_transcript` with `messages=[{role: "user", content: "Hi."}, {role: "assistant", content: "Hello!"}]` And the resulting `transcript_id` is saved as `transcript_id` - Then `get` on the `Transcript` for "" has `messages` of length `2` and `messages[0].content="Hi."` and `messages[1].content="Hello!"` + Then as "alice" `get` on the `Transcript` for "" has `messages` of length `2` and `messages[0].content="Hi."` and `messages[1].content="Hello!"` diff --git a/reboot/examples/bank-pydantic/backend/tests/deposits.feature b/reboot/examples/bank-pydantic/backend/tests/deposits.feature index 6c634ba21..863347ddd 100644 --- a/reboot/examples/bank-pydantic/backend/tests/deposits.feature +++ b/reboot/examples/bank-pydantic/backend/tests/deposits.feature @@ -3,7 +3,6 @@ Feature: Depositing into an account Background: Given the application is up - And the user is unauthenticated Rule: A deposit raises the balance by the amount diff --git a/reboot/examples/bank-pydantic/backend/tests/transfers.feature b/reboot/examples/bank-pydantic/backend/tests/transfers.feature index 8616191af..57710000a 100644 --- a/reboot/examples/bank-pydantic/backend/tests/transfers.feature +++ b/reboot/examples/bank-pydantic/backend/tests/transfers.feature @@ -5,7 +5,6 @@ Feature: Transferring money between accounts Background: Given the application is up - And the user is unauthenticated Rule: A transfer moves exactly the amount from one account to the other Neither account sees any other change, and the bank's view of diff --git a/reboot/examples/bank-pydantic/backend/tests/withdrawals.feature b/reboot/examples/bank-pydantic/backend/tests/withdrawals.feature index 0b1722537..2652454ef 100644 --- a/reboot/examples/bank-pydantic/backend/tests/withdrawals.feature +++ b/reboot/examples/bank-pydantic/backend/tests/withdrawals.feature @@ -4,7 +4,6 @@ Feature: Withdrawing from an account Background: Given the application is up - And the user is unauthenticated Scenario: Withdrawing part of the balance leaves the rest Given an `Account` for "part-account" gets created via `open` diff --git a/reboot/examples/chat-room/backend/tests/chat_room.feature b/reboot/examples/chat-room/backend/tests/chat_room.feature index 6b52225d4..b8b6d1dbc 100644 --- a/reboot/examples/chat-room/backend/tests/chat_room.feature +++ b/reboot/examples/chat-room/backend/tests/chat_room.feature @@ -2,7 +2,6 @@ Feature: Chat room Background: Given the application is up - And the user is unauthenticated Scenario: Messages record in order When the `ChatRoom` for "testing-chat-room" gets a `send` with `message="Hello, World"` diff --git a/reboot/examples/chick-potle/backend/tests/food.feature b/reboot/examples/chick-potle/backend/tests/food.feature index ba47167d7..fbb4d07fa 100644 --- a/reboot/examples/chick-potle/backend/tests/food.feature +++ b/reboot/examples/chick-potle/backend/tests/food.feature @@ -2,42 +2,42 @@ Feature: Food orders Background: Given the application is up - And the authenticated user is "alice" + And "alice" is an authenticated user Scenario: Starting an order pre-populates the menu with an empty cart - When the `User` for "alice" gets a `start_order` + When as "alice" the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` - Then `get_menu` on the `FoodOrder` for "" has `items` of length `10` and `items[0].name="Chicken Burrito"` and `items[0].category="Burritos"` and `items[0].price_cents=1115` - And `get_cart` on the `FoodOrder` for "" has `entries=[]` and `total_cents=0` + Then as "alice" `get_menu` on the `FoodOrder` for "" has `items` of length `10` and `items[0].name="Chicken Burrito"` and `items[0].category="Burritos"` and `items[0].price_cents=1115` + And as "alice" `get_cart` on the `FoodOrder` for "" has `entries=[]` and `total_cents=0` Scenario: Adding the same item twice increments its quantity - Given the `User` for "alice" gets a `start_order` + Given as "alice" the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` - When the `FoodOrder` for "" gets a `add_to_cart` with `item_index=0` and `quantity=1` - And the `FoodOrder` for "" gets a `add_to_cart` with `item_index=0` and `quantity=1` - And the `FoodOrder` for "" gets a `add_to_cart` with `item_index=1` and `quantity=1` - Then `get_cart` on the `FoodOrder` for "" has `entries` of length `2` and `entries[0].item_index=0` and `entries[0].quantity=2` and `entries[1].item_index=1` and `entries[1].quantity=1` and `total_cents=3470` - When the `FoodOrder` for "" gets a `remove_from_cart` with `item_index=0` - Then `get_cart` on the `FoodOrder` for "" has `entries` of length `1` and `entries[0].item_index=1` and `total_cents=1240` + When as "alice" the `FoodOrder` for "" gets a `add_to_cart` with `item_index=0` and `quantity=1` + And as "alice" the `FoodOrder` for "" gets a `add_to_cart` with `item_index=0` and `quantity=1` + And as "alice" the `FoodOrder` for "" gets a `add_to_cart` with `item_index=1` and `quantity=1` + Then as "alice" `get_cart` on the `FoodOrder` for "" has `entries` of length `2` and `entries[0].item_index=0` and `entries[0].quantity=2` and `entries[1].item_index=1` and `entries[1].quantity=1` and `total_cents=3470` + When as "alice" the `FoodOrder` for "" gets a `remove_from_cart` with `item_index=0` + Then as "alice" `get_cart` on the `FoodOrder` for "" has `entries` of length `1` and `entries[0].item_index=1` and `total_cents=1240` Scenario: A quantity of zero means one - Given the `User` for "alice" gets a `start_order` + Given as "alice" the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` - When the `FoodOrder` for "" gets a `add_to_cart` with `item_index=0` and `quantity=0` - Then `get_cart` on the `FoodOrder` for "" has `entries` of length `1` and `entries[0].quantity=1` + When as "alice" the `FoodOrder` for "" gets a `add_to_cart` with `item_index=0` and `quantity=0` + Then as "alice" `get_cart` on the `FoodOrder` for "" has `entries` of length `1` and `entries[0].quantity=1` Scenario: Out-of-range menu indexes are refused - Given the `User` for "alice" gets a `start_order` + Given as "alice" the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` - When the `FoodOrder` for "" attempts a `add_to_cart` with `item_index=10` and `quantity=1` + When as "alice" the `FoodOrder` for "" attempts a `add_to_cart` with `item_index=10` and `quantity=1` Then the attempt aborts with `Unknown` - When the `FoodOrder` for "" attempts a `add_to_cart` with `item_index=-1` and `quantity=1` + When as "alice" the `FoodOrder` for "" attempts a `add_to_cart` with `item_index=-1` and `quantity=1` Then the attempt aborts with `Unknown` Scenario: Another user cannot touch the order - Given the `User` for "alice" gets a `start_order` + Given as "alice" the `User` for "alice" gets a `start_order` And the resulting `order_id` is saved as `order_id` - When the authenticated user is "bob" - Then `get_cart` on the `FoodOrder` for "" aborts with `PermissionDenied` - When the `FoodOrder` for "" attempts a `add_to_cart` with `item_index=0` and `quantity=1` + And "bob" is an authenticated user + Then as "bob" `get_cart` on the `FoodOrder` for "" aborts with `PermissionDenied` + When as "bob" the `FoodOrder` for "" attempts a `add_to_cart` with `item_index=0` and `quantity=1` Then the attempt aborts with `PermissionDenied` diff --git a/reboot/examples/chick-potle/backend/tests/food_test.py b/reboot/examples/chick-potle/backend/tests/food_test.py index 15aa4c63b..c695729f9 100644 --- a/reboot/examples/chick-potle/backend/tests/food_test.py +++ b/reboot/examples/chick-potle/backend/tests/food_test.py @@ -1,7 +1,7 @@ """The chick-potle tests: the Gherkin scenarios in `food.feature`. The scenarios register the real servicers with their real -authorizers and say who the authenticated user is, so the +authorizers and declare the users the steps call as, so the authorization rules run in every scenario, exactly as in production; minting the user's token also constructs their `User` state, the way a production sign-in does. diff --git a/reboot/examples/monorepo/bank/backend/tests/account.feature b/reboot/examples/monorepo/bank/backend/tests/account.feature index cac9b4a12..6b3942240 100644 --- a/reboot/examples/monorepo/bank/backend/tests/account.feature +++ b/reboot/examples/monorepo/bank/backend/tests/account.feature @@ -2,7 +2,6 @@ Feature: Accounts Background: Given the application is up - And the user is unauthenticated Scenario: Depositing and withdrawing move the balance Given an `Account` for "alice" gets created via `open` with `customer_name="Alice"` diff --git a/reboot/examples/monorepo/bank/backend/tests/bank.feature b/reboot/examples/monorepo/bank/backend/tests/bank.feature index 38f04026c..2f59e8a67 100644 --- a/reboot/examples/monorepo/bank/backend/tests/bank.feature +++ b/reboot/examples/monorepo/bank/backend/tests/bank.feature @@ -2,7 +2,6 @@ Feature: Bank Background: Given the application is up - And the user is unauthenticated Scenario: Signing up opens an account When the `Bank` for "my-bank" gets a `sign_up` with `customer_name="Alice"` diff --git a/reboot/examples/monorepo/hello-constructors/backend/tests/hello.feature b/reboot/examples/monorepo/hello-constructors/backend/tests/hello.feature index 38e5cad03..d45fab679 100644 --- a/reboot/examples/monorepo/hello-constructors/backend/tests/hello.feature +++ b/reboot/examples/monorepo/hello-constructors/backend/tests/hello.feature @@ -2,7 +2,6 @@ Feature: Hello with a factory Background: Given the application is up - And the user is unauthenticated Scenario: Messages record from creation onward Given a `Hello` for "greetings" gets created via `create` with `initial_message="first message"` diff --git a/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature b/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature index 67178b1ea..da511ed13 100644 --- a/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature +++ b/reboot/examples/monorepo/hello-tasks/backend/tests/hello.feature @@ -2,7 +2,6 @@ Feature: Hello with tasks Background: Given the application is up - And the user is unauthenticated Scenario: Sent messages get a warning and then erase When the `Hello` for "testing-hello" gets a `send` with `message="Hello, World!"` diff --git a/reboot/examples/reboot-swag-store/backend/tests/store.feature b/reboot/examples/reboot-swag-store/backend/tests/store.feature index a1c70ce90..15c982dc7 100644 --- a/reboot/examples/reboot-swag-store/backend/tests/store.feature +++ b/reboot/examples/reboot-swag-store/backend/tests/store.feature @@ -2,85 +2,82 @@ Feature: Swag store Background: Given the application is up - And the authenticated user is "test-user" + And "test-user" is an authenticated user Scenario: The catalog lists unfiltered, in order - Then `list_products` on the `User` for "test-user" has `products` of length `3` and `products[0].id="hat-1"` and `products[1].id="hoodie-1"` and `products[2].id="tee-1"` + Then as "test-user" `list_products` on the `User` for "test-user" has `products` of length `3` and `products[0].id="hat-1"` and `products[1].id="hoodie-1"` and `products[2].id="tee-1"` Scenario: Another user cannot read the cart - Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` - And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - When the authenticated user is "other-user" - Then `get_cart` on the `Cart` for "cart-1" aborts with `PermissionDenied` - When the authenticated user is "test-user" - Then `get_cart` on the `Cart` for "cart-1" has `items` of length `1` + Given as "test-user" a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + And as "test-user" the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + And "other-user" is an authenticated user + Then as "other-user" `get_cart` on the `Cart` for "cart-1" aborts with `PermissionDenied` + And as "test-user" `get_cart` on the `Cart` for "cart-1" has `items` of length `1` Scenario: Added items appear in the cart - Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` - When the `Cart` for "cart-1" gets a `add_item` with `quantity=2` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - Then `get_cart` on the `Cart` for "cart-1" has `items` of length `1` and `items[0].product_id="hoodie-1"` and `items[0].name="Reboot Hoodie"` and `items[0].size="L"` and `items[0].quantity=2` + Given as "test-user" a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + When as "test-user" the `Cart` for "cart-1" gets a `add_item` with `quantity=2` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + Then as "test-user" `get_cart` on the `Cart` for "cart-1" has `items` of length `1` and `items[0].product_id="hoodie-1"` and `items[0].name="Reboot Hoodie"` and `items[0].size="L"` and `items[0].quantity=2` Scenario: Adding the same variant increments its quantity - Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` - When the `Cart` for "cart-1" gets a `add_item` with `quantity=2` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - Then `get_cart` on the `Cart` for "cart-1" has `items` of length `1` and `items[0].quantity=3` + Given as "test-user" a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + When as "test-user" the `Cart` for "cart-1" gets a `add_item` with `quantity=2` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + And as "test-user" the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + Then as "test-user" `get_cart` on the `Cart` for "cart-1" has `items` of length `1` and `items[0].quantity=3` Scenario: Adding a different variant adds a line - Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` - When the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-s"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="S"` - Then `get_cart` on the `Cart` for "cart-1" has `items` of length `2` and `items[0].size="L"` and `items[1].size="S"` + Given as "test-user" a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + When as "test-user" the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + And as "test-user" the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-s"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="S"` + Then as "test-user" `get_cart` on the `Cart` for "cart-1" has `items` of length `2` and `items[0].size="L"` and `items[1].size="S"` Scenario: Removed items leave the cart - Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` - When the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - And the `Cart` for "cart-1" gets a `remove_item` with `product_id="hoodie-1"` - Then `get_cart` on the `Cart` for "cart-1" has `items=[]` + Given as "test-user" a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + When as "test-user" the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + And as "test-user" the `Cart` for "cart-1" gets a `remove_item` with `product_id="hoodie-1"` + Then as "test-user" `get_cart` on the `Cart` for "cart-1" has `items=[]` Scenario: Checking out an empty cart is refused - Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` - When the `Cart` for "cart-1" attempts a `checkout` with `coupon_code="000000"` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + Given as "test-user" a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + When as "test-user" the `Cart` for "cart-1" attempts a `checkout` with `coupon_code="000000"` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` Then the attempt aborts with `CartEmpty` Scenario: An invalid coupon refuses checkout and keeps the cart - Given a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` - And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - When the `Cart` for "cart-1" attempts a `checkout` with `coupon_code="definitely-not-a-real-code"` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + Given as "test-user" a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + And as "test-user" the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + When as "test-user" the `Cart` for "cart-1" attempts a `checkout` with `coupon_code="definitely-not-a-real-code"` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` Then the attempt aborts with `InvalidCoupon` - And `get_cart` on the `Cart` for "cart-1" has `items` of length `1` + And as "test-user" `get_cart` on the `Cart` for "cart-1" has `items` of length `1` Scenario: Checkout empties the cart and creates the order - Given the bearer token is "test-admin-key" - And the `CouponBook` for "coupon-book" gets a `generate_codes` + Given "admin" has the bearer token "test-admin-key" + And as "admin" the `CouponBook` for "coupon-book" gets a `generate_codes` And the resulting `codes[0]` is saved as `coupon_code` - And the authenticated user is "test-user" - And a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` - And the `Cart` for "cart-1" gets a `add_item` with `quantity=2` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - When the `Cart` for "cart-1" gets a `checkout` with `coupon_code=` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + And as "test-user" a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + And as "test-user" the `Cart` for "cart-1" gets a `add_item` with `quantity=2` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + When as "test-user" the `Cart` for "cart-1" gets a `checkout` with `coupon_code=` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` And the resulting `order_id` is saved as `order_id` - Then `get_cart` on the `Cart` for "cart-1" has `items=[]` - And `get_details` on the `Order` for "" has `order_id=` and `items` of length `1` and `items[0].product_id="hoodie-1"` and `items[0].quantity=2` and `subtotal_cents=8000` and `total_cents=0` + Then as "test-user" `get_cart` on the `Cart` for "cart-1" has `items=[]` + And as "test-user" `get_details` on the `Order` for "" has `order_id=` and `items` of length `1` and `items[0].product_id="hoodie-1"` and `items[0].quantity=2` and `subtotal_cents=8000` and `total_cents=0` Scenario: A redeemed coupon cannot be reused - Given the bearer token is "test-admin-key" - And the `CouponBook` for "coupon-book" gets a `generate_codes` + Given "admin" has the bearer token "test-admin-key" + And as "admin" the `CouponBook` for "coupon-book" gets a `generate_codes` And the resulting `codes[0]` is saved as `coupon_code` - And the authenticated user is "test-user" - And a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` - And the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - And the `Cart` for "cart-1" gets a `checkout` with `coupon_code=` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` - And a `Cart` for "cart-2" gets created via `create` with `owner_id="test-user"` - And the `Cart` for "cart-2" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` - When the `Cart` for "cart-2" attempts a `checkout` with `coupon_code=` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + And as "test-user" a `Cart` for "cart-1" gets created via `create` with `owner_id="test-user"` + And as "test-user" the `Cart` for "cart-1" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + And as "test-user" the `Cart` for "cart-1" gets a `checkout` with `coupon_code=` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` + And as "test-user" a `Cart` for "cart-2" gets created via `create` with `owner_id="test-user"` + And as "test-user" the `Cart` for "cart-2" gets a `add_item` with `quantity=1` and `product_id="hoodie-1"` and `variant_id="hoodie-1-l"` and `name="Reboot Hoodie"` and `price_cents=4000` and `image_url=""` and `size="L"` + When as "test-user" the `Cart` for "cart-2" attempts a `checkout` with `coupon_code=` and `shipping_address={name: "Jane Doe", email: "jane@example.com", address1: "123 Main St", address2: "", city: "Seattle", state_code: "WA", zip_code: "98101", country_code: "US"}` Then the attempt aborts with `InvalidCoupon` Scenario: Generating coupon codes requires the admin bearer token - When the `CouponBook` for "coupon-book" attempts a `generate_codes` + When as "test-user" the `CouponBook` for "coupon-book" attempts a `generate_codes` Then the attempt aborts with `PermissionDenied` Scenario: The admin bearer token generates fresh six-digit codes - Given the bearer token is "test-admin-key" - When the `CouponBook` for "coupon-book" gets a `generate_codes` + Given "admin" has the bearer token "test-admin-key" + When as "admin" the `CouponBook` for "coupon-book" gets a `generate_codes` Then the result has `codes` of length `20` And every generated code is six digits diff --git a/tests/reboot/bdd/accounts.feature b/tests/reboot/bdd/accounts.feature index 3230df5f8..d5e2ca616 100644 --- a/tests/reboot/bdd/accounts.feature +++ b/tests/reboot/bdd/accounts.feature @@ -2,7 +2,6 @@ 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` @@ -72,12 +71,18 @@ Feature: Accounts When the `Account` for "dave" gets a `deposit` with `amount=5` Then `balance` on the `Account` for "dave" has `balance=5` - Scenario: Steps call as the authenticated user - Given the authenticated user is "alice" - And an `Account` for "joint" gets created via `open` - Then `whoami` on the `Account` for "joint" has `user_id="alice"` - When the authenticated user is "bob" - Then `whoami` on the `Account` for "joint" has `user_id="bob"` + Scenario: A shared context calls as one user + Given "carol" is an authenticated user + And as "carol" a shared context + And as "carol" an `Account` for "shared" gets created via `open` + Then as "carol" `whoami` on the `Account` for "shared" has `user_id="carol"` + + Scenario: Steps call as the user they name + Given "alice" is an authenticated user + And "bob" is an authenticated user + And as "alice" an `Account` for "joint" gets created via `open` + Then as "alice" `whoami` on the `Account` for "joint" has `user_id="alice"` + And as "bob" `whoami` on the `Account` for "joint" has `user_id="bob"` Scenario: Effects land eventually Given an `Account` for "slow" gets created via `open` diff --git a/tests/reboot/bdd/bdd_tests.py b/tests/reboot/bdd/bdd_tests.py index b460f3dbe..88d284e1e 100644 --- a/tests/reboot/bdd/bdd_tests.py +++ b/tests/reboot/bdd/bdd_tests.py @@ -12,7 +12,6 @@ from pytest_bdd import parsers, scenarios from reboot.aio.applications import Application from reboot.aio.external import ExternalContext -from reboot.aio.tests import Reboot from reboot.bdd import when from reboot.bdd.fixtures import JsonValue, PropertyPath, World from reboot.bdd.grammar import ( @@ -41,10 +40,10 @@ _almost_within_needs_eventually, _assert_aborted, _assert_properties, + _has_bearer_token, _parse_assertions, _parse_assignments, _parse_saves, - _the_bearer_token_is, ) from tests.reboot.bdd.account_pb2 import ( BalanceResponse, @@ -95,30 +94,32 @@ def test_is_reader() -> None: assert not world.is_reader(state_type='Account', method='deposit') -def test_the_bearer_token_is() -> None: +def test_has_bearer_token() -> None: world = World() - _the_bearer_token_is(world, 'admin-key') - assert world.bearer_token == 'admin-key' + _has_bearer_token(world, 'admin', 'admin-key') + assert world.token('admin') == 'admin-key' world.saved['token'] = 'saved-key' - _the_bearer_token_is(world, '') - assert world.bearer_token == 'saved-key' + _has_bearer_token(world, 'other', '') + assert world.token('other') == 'saved-key' -def test_context_requires_user_declared() -> None: - world = World(rbt=cast(Reboot, object()), name='test') - with pytest.raises(ValueError, match="has not declared a user"): - world.context() - world.set_bearer_token(None) - assert world.user_declared +def test_token_of_an_undeclared_user() -> None: + world = World() + assert world.token(None) is None + with pytest.raises(ValueError, match='"bob" is not a user the scenario'): + world.token('bob') -def test_set_bearer_token_guard() -> None: +def test_shared_context_calls_as_one_user() -> None: world = World() - world.set_bearer_token('token') - assert world.bearer_token == 'token' + world.declare_user('alice', 'token') world.shared_context = cast(ExternalContext, object()) - with pytest.raises(ValueError, match="before 'Given a shared context'"): - world.set_bearer_token('other') + world.shared_user = 'alice' + assert world.context('alice') is world.shared_context + with pytest.raises(ValueError, match='cannot call as nobody'): + world.context() + with pytest.raises(ValueError, match='cannot call as "bob"'): + world.context('bob') def test_clause_grammar_routing() -> None: diff --git a/tests/reboot/bdd/collisions.feature b/tests/reboot/bdd/collisions.feature index 94e20d76e..ea66c2631 100644 --- a/tests/reboot/bdd/collisions.feature +++ b/tests/reboot/bdd/collisions.feature @@ -2,7 +2,6 @@ Feature: Colliding state type names Background: Given the application is up - And the user is unauthenticated Scenario: Full state type names disambiguate Given a `tests.reboot.bdd.Account` for "alice" gets created via `open` with `initial_balance=1` diff --git a/tests/reboot/bdd/grammar_tests.py b/tests/reboot/bdd/grammar_tests.py index 6417d2c2d..8daa98974 100644 --- a/tests/reboot/bdd/grammar_tests.py +++ b/tests/reboot/bdd/grammar_tests.py @@ -178,17 +178,57 @@ def test_identity_and_application_steps(self) -> None: self.assertEqual(syntax.WhichOneof('step'), 'application_is_up') self.assertFalse(syntax.application_is_up.HasField('name')) - syntax = parse('the authenticated user is "alice"') + syntax = parse('"alice" is an authenticated user') assert syntax is not None - self.assertEqual(syntax.authenticated_user_is.user_id, 'alice') + self.assertEqual(syntax.is_an_authenticated_user.user_id, 'alice') - syntax = parse('the user is unauthenticated') + syntax = parse('"admin" has the bearer token "S3CR3T!"') assert syntax is not None - self.assertEqual(syntax.WhichOneof('step'), 'user_is_unauthenticated') + self.assertEqual(syntax.has_bearer_token.user_id, 'admin') + self.assertEqual(syntax.has_bearer_token.bearer_token, 'S3CR3T!') - syntax = parse('the bearer token is "S3CR3T!"') + syntax = parse('a shared context') assert syntax is not None - self.assertEqual(syntax.bearer_token_is.bearer_token, 'S3CR3T!') + self.assertEqual(syntax.WhichOneof('step'), 'shared_context') + self.assertFalse(syntax.shared_context.HasField('user')) + + syntax = parse('as "alice" a shared context') + assert syntax is not None + self.assertEqual(syntax.shared_context.user, 'alice') + + def test_a_step_names_who_calls(self) -> None: + """A step starting 'as "..."' calls as that user; one without + calls anonymously.""" + syntax = parse( + 'as "alice" the `Account` for "a" gets a `deposit` with ' + '`amount=1`' + ) + assert syntax is not None + self.assertEqual(syntax.gets.user, 'alice') + self.assertEqual(syntax.gets.state.id, 'a') + + syntax = parse( + 'the `Account` for "a" gets a `deposit` with `amount=1`' + ) + assert syntax is not None + self.assertFalse(syntax.gets.HasField('user')) + + syntax = parse( + 'as "bob" `balance` on the `Account` for "a" has `balance=1`' + ) + assert syntax is not None + self.assertEqual(syntax.has.user, 'bob') + + syntax = parse( + 'as "bob" `balance` on the `Account` for "a" aborts with ' + '`PermissionDenied`' + ) + assert syntax is not None + self.assertEqual(syntax.aborts_with.user, 'bob') + + syntax = parse('as "bob" an `Account` for "a" gets created via `open`') + assert syntax is not None + self.assertEqual(syntax.gets_created_via.user, 'bob') def test_a_step_the_grammar_does_not_define_is_none(self) -> None: self.assertIsNone(parse('the welcome email was sent')) diff --git a/tests/reboot/bdd/pydantic/accounts.feature b/tests/reboot/bdd/pydantic/accounts.feature index 0e99cdeff..db47f2381 100644 --- a/tests/reboot/bdd/pydantic/accounts.feature +++ b/tests/reboot/bdd/pydantic/accounts.feature @@ -2,7 +2,6 @@ Feature: Accounts with a pydantic API 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` @@ -43,12 +42,12 @@ Feature: Accounts with a pydantic API And `get_owners` on the `Account` for "heidi" has `owners={main: {name: "Heidi", tags: ["a"]}}` And `get_owners` on the `Account` for "heidi" has `owners` containing `"main"` and `owners` of length `1` - Scenario: Steps call as the authenticated user - Given the authenticated user is "alice" - And an `Account` for "joint" gets created via `open` - Then `whoami` on the `Account` for "joint" has `user_id="alice"` - When the authenticated user is "bob" - Then `whoami` on the `Account` for "joint" has `user_id="bob"` + Scenario: Steps call as the user they name + Given "alice" is an authenticated user + And "bob" is an authenticated user + And as "alice" an `Account` for "joint" gets created via `open` + Then as "alice" `whoami` on the `Account` for "joint" has `user_id="alice"` + And as "bob" `whoami` on the `Account` for "joint" has `user_id="bob"` Scenario: Effects land eventually Given an `Account` for "slow" gets created via `open` diff --git a/tests/reboot/bdd/variation.feature b/tests/reboot/bdd/variation.feature index 407477611..b67aff38d 100644 --- a/tests/reboot/bdd/variation.feature +++ b/tests/reboot/bdd/variation.feature @@ -2,12 +2,10 @@ Feature: Choosing the application Scenario: A scenario picks its application by name Given the "two accounts" application is up - And the user is unauthenticated And a `tests.reboot.bdd.other.Account` for "vary" gets created via `open` with `initial_total=7` Then `total` on the `tests.reboot.bdd.other.Account` for "vary" has `total=7` Scenario: The unnamed application is the `application` fixture Given the application is up - And the user is unauthenticated And an `Account` for "vary" gets created via `open` with `initial_balance=3` Then `balance` on the `Account` for "vary" has `balance=3` From 44fffbd4a5bd49c7fe01d924590a7e844968816d Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Sat, 5 Sep 2026 19:39:04 +0000 Subject: [PATCH 34/42] Add browser steps to `reboot.bdd`, driven with Playwright A scenario can now use an application's web app the way its users do, in a browser, and mix that with the backend steps: When "alice" opens the web app And "alice" fills "Initial Deposit ($)" in the web app with `1000` And "alice" clicks the "Open Account" button in the web app Then "alice" eventually sees "$1000" in the "Your Accounts" table in the web app within 10 seconds And as "alice" `balance` on the `Account` for "" has `balance=1000` Each step names the user acting, a user the scenario declared, and "the web app", leaving room for a mobile app or an MCP host to get their own phrase against their own fixture. An element is named by what it is and what it says, 'the "Open Account" button', from a closed list of ARIA roles the grammar's `Element.Role` enum defines, or a field by its label, never by a selector; the one step that reads a value out of the app, 'saves the text of the "account-id" element ... as `account_id`', names it by test id. Quoted text may say '' for a saved value, and a filled value is a JSON5 literal in backticks like the backend steps' values. 'sees' looks now, 'does not see' too, and 'eventually sees ... within 10 seconds' waits. Each user gets a browser context of their own, so two users can be in the app at once in one scenario. The steps are plain `def`s in `reboot.bdd.web`: Playwright's sync API runs on pytest's main thread, while the backend steps run on the scenario's event loop. `WebApp` keeps a page per user who has opened the app and opens it as that user: the user's token becomes the backend host's `rbt_session` cookie, `Secure` and `SameSite=None` like the one the backend sets, and the app's credentialed `/__/oauth/whoami` call turns it back into the bearer. The cookie is added by `domain`, since Chromium drops a `Secure` cookie added for an `http://` URL without a word. The steps register through the plugin only when `playwright` and `pytest-playwright` are installed. A `Frontend` is what a scenario's users use, served against the scenario's backend: 'the application is up' starts serving the `frontend` fixture, if the test module defines one, and the opening step waits until it is ready. `reboot.bdd.vite.vite(directory=)` serves a Vite app from its own origin on a port reserved through Vite's bind, with the backend's Envoy address as `VITE_REBOOT_URL`, a fresh server per scenario so scenarios stay hermetic; it fails fast with Vite's output when Vite exits or is not serving after a minute, and raises when the directory or its `vite` binary is missing. The browser treats the app's origin and the backend's `127.0.0.1` address as different sites, so the application must list the frontend's origin in `allowed_origins`, the way it is deployed. The grammar's typed syntax has a message per web step and the Behaviors page prints them with the user, element, label, text, key and path each in a span of their own. bank-pydantic's `web.feature` opens an account in the app and transfers between two accounts the backend steps opened, picking their saved ids in the app's selects; its 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. `find_project_root_from` accepts a directory as well as a file. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015whh5EmdU8PAoxSevMRn5m --- rbt/v1alpha1/bdd/grammar.proto | 129 +++++++ reboot/BUILD.bazel | 2 + reboot/bdd/BUILD.bazel | 46 +++ reboot/bdd/frontend.py | 50 +++ reboot/bdd/grammar.py | 149 ++++++++ reboot/bdd/steps.py | 15 + reboot/bdd/vite.py | 168 +++++++++ reboot/bdd/web.py | 302 +++++++++++++++ reboot/bdd_plugin.py | 9 +- reboot/dashboard/web/dashboard.css | 16 + reboot/dashboard/web/src/behaviors.ts | 162 +++++++- reboot/examples/bank-pydantic/.mypy.ini | 2 +- reboot/examples/bank-pydantic/.tests/test.sh | 4 +- .../bank-pydantic/backend/tests/web.feature | 35 ++ .../bank-pydantic/backend/tests/web_test.py | 77 ++++ .../bank-pydantic/frontend/web/src/App.tsx | 50 ++- reboot/examples/bank-pydantic/pyproject.toml | 2 + reboot/examples/bank-pydantic/uv.lock | 348 ++++++++++++++++++ reboot/mcp/ui.py | 12 +- tests/reboot/bdd/grammar_tests.py | 95 ++++- 20 files changed, 1655 insertions(+), 18 deletions(-) create mode 100644 reboot/bdd/frontend.py create mode 100644 reboot/bdd/vite.py create mode 100644 reboot/bdd/web.py create mode 100644 reboot/examples/bank-pydantic/backend/tests/web.feature create mode 100644 reboot/examples/bank-pydantic/backend/tests/web_test.py diff --git a/rbt/v1alpha1/bdd/grammar.proto b/rbt/v1alpha1/bdd/grammar.proto index 8939a9310..cfd5e8a03 100644 --- a/rbt/v1alpha1/bdd/grammar.proto +++ b/rbt/v1alpha1/bdd/grammar.proto @@ -233,6 +233,125 @@ message ResultHas { repeated Assertion assertions = 1; } +// An element of the web app a step names by what it is and what it +// says: 'the "Open Account" button'. The name is the element's +// accessible name as written, which may hold a variable. +message Element { + // What an element may be: the ARIA roles a step may name, each + // written in a step as its name in lower case. + enum Role { + ROLE_UNSPECIFIED = 0; + BUTTON = 1; + LINK = 2; + TAB = 3; + CHECKBOX = 4; + RADIO = 5; + MENUITEM = 6; + OPTION = 7; + ROW = 8; + TABLE = 9; + } + + Role role = 1; + + string name = 2; +} + +// '"alice" opens the web app', optionally 'at "/path"'. +message OpensWebApp { + string user = 1; + + optional string path = 2; +} + +// '"alice" clicks the "Open Account" button in the web app'. +message ClicksInWebApp { + string user = 1; + + Element element = 2; +} + +// '"alice" fills "Amount ($)" in the web app with `250`': the field +// named by its label. +message FillsInWebApp { + string user = 1; + + string label = 2; + + Value value = 3; +} + +// '"alice" selects "" in "From Account" in the web app': the +// option, by its text, of the select named by its label. +message SelectsInWebApp { + string user = 1; + + string option = 2; + + string label = 3; +} + +// '"alice" checks "Remember me" in the web app', or unchecks. +message ChecksInWebApp { + string user = 1; + + string label = 2; + + bool checked = 3; +} + +// '"alice" presses "Enter" in the web app'. +message PressesInWebApp { + string user = 1; + + string key = 2; +} + +// '"alice" sees "$1000" in the web app', optionally 'in the "Your +// Accounts" table' before 'in the web app'; 'does not see' for the +// text's absence; 'eventually sees ... within 10 seconds' for text +// that arrives. +message SeesInWebApp { + string user = 1; + + string text = 2; + + // The element the text is looked for in; absent for the page. + optional Element within = 3; + + bool negated = 4; + + // Absent for a plain 'sees'. + optional double seconds = 5; +} + +// '"alice" sees the "Transfer Funds" button in the web app is +// disabled', or enabled. +message SeesEnabledInWebApp { + string user = 1; + + Element element = 2; + + bool enabled = 3; +} + +// '"alice" sees the web app at "/accounts"'. +message SeesWebAppAt { + string user = 1; + + string path = 2; +} + +// '"alice" saves the text of the "account-id" element in the web app +// as `account_id`': the element named by its test id. +message SavesTextInWebAppAs { + string user = 1; + + string test_id = 2; + + string name = 3; +} + // 'the resulting `account_id` is saved as `alice_account_id`'. message ResultingIsSavedAs { Save save = 1; @@ -259,5 +378,15 @@ message BuiltInSyntax { AbortsWith aborts_with = 13; ResultHas result_has = 14; ResultingIsSavedAs resulting_is_saved_as = 15; + OpensWebApp opens_web_app = 16; + ClicksInWebApp clicks_in_web_app = 17; + FillsInWebApp fills_in_web_app = 18; + SelectsInWebApp selects_in_web_app = 19; + ChecksInWebApp checks_in_web_app = 20; + PressesInWebApp presses_in_web_app = 21; + SeesInWebApp sees_in_web_app = 22; + SeesEnabledInWebApp sees_enabled_in_web_app = 23; + SeesWebAppAt sees_web_app_at = 24; + SavesTextInWebAppAs saves_text_in_web_app_as = 25; } } diff --git a/reboot/BUILD.bazel b/reboot/BUILD.bazel index 6f7dff04b..59de075e9 100644 --- a/reboot/BUILD.bazel +++ b/reboot/BUILD.bazel @@ -553,6 +553,8 @@ py_library( ":python_std", ":python_thirdparty", "//reboot/aio:python", + "//reboot/bdd:vite_py", + "//reboot/bdd:web_py", "//reboot/cli:main_py", "//reboot/dashboard/backend:main_py", "//reboot/mcp:python", diff --git a/reboot/bdd/BUILD.bazel b/reboot/bdd/BUILD.bazel index 960c1913d..9034f9a00 100644 --- a/reboot/bdd/BUILD.bazel +++ b/reboot/bdd/BUILD.bazel @@ -41,6 +41,51 @@ py_library( ], ) +py_library( + name = "frontend_py", + srcs = ["frontend.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + "//reboot/aio:tests_py", + ], +) + +# The web app's steps need `playwright` and `pytest-playwright`, which +# a project installs to drive its web app; the wheel carries the +# module, and `reboot.bdd_plugin` registers it only when they are +# installed. +py_library( + name = "web_py", + srcs = ["web.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + ":__init___py", + ":fixtures_py", + ":frontend_py", + ":grammar_py", + ":loop_py", + ":steps_py", + requirement("pytest"), + requirement("pytest-bdd"), + "//reboot/aio/auth:__init___py", + ], +) + +py_library( + name = "vite_py", + srcs = ["vite.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [ + ":frontend_py", + ":loop_py", + requirement("aiohttp"), + "//reboot/mcp:ui_py", + ], +) + py_library( name = "registry_py", srcs = ["registry.py"], @@ -78,6 +123,7 @@ py_library( deps = [ ":__init___py", ":fixtures_py", + ":frontend_py", ":grammar_py", ":registry_py", requirement("json5"), diff --git a/reboot/bdd/frontend.py b/reboot/bdd/frontend.py new file mode 100644 index 000000000..3df564742 --- /dev/null +++ b/reboot/bdd/frontend.py @@ -0,0 +1,50 @@ +"""The client side of the application under test. + +A frontend is a web app, a mobile app, or an MCP UI. Each scenario +gets one of its own, served fresh once the scenario's backend is up +and stopped when the scenario ends, so that nothing carries over from +one scenario to the next. How it is driven is the kind's own affair: +a browser for a web app, a device for a mobile app, an MCP host for +an MCP UI. + +The `frontend` fixture is a `Frontend`. A project defines it for +however it serves its frontend; `reboot.bdd.vite` serves a web app +with a Vite dev server. +""" +from reboot.aio.tests import Reboot +from typing import Optional + + +def backend_url(rbt: Reboot) -> str: + """The backend's address for a frontend, on `127.0.0.1` so that it + is a different site from a frontend served on `localhost`, the way + a deployed frontend and its backend are.""" + return f'http://127.0.0.1:{rbt.envoy_port()}' + + +class Frontend: + """One scenario's frontend: where it is served from, and serving + it once the backend it calls is up.""" + + def __init__(self, origin: Optional[str]) -> None: + self._origin = origin + + @property + def origin(self) -> Optional[str]: + """Where a browser loads the frontend from, which is what the + backend must allow cross-origin requests from; `None` for a + frontend that makes no cross-origin requests, such as a native + mobile app.""" + return self._origin + + async def serve(self, *, backend_url: str) -> None: + """Starts serving the frontend, calling the backend at the + given URL, and returns at once; `ready()` is what waits for it + to answer. Serving ends with the fixture that made the + frontend.""" + raise NotImplementedError + + async def ready(self) -> None: + """Returns once the frontend answers, and raises once it is + clear that it never will.""" + raise NotImplementedError diff --git a/reboot/bdd/grammar.py b/reboot/bdd/grammar.py index 9a9c446a0..789229399 100644 --- a/reboot/bdd/grammar.py +++ b/reboot/bdd/grammar.py @@ -17,9 +17,13 @@ AttemptAbortsWith, Attempts, BuiltInSyntax, + ChecksInWebApp, + ClicksInWebApp, Containing, + Element, Equals, EventuallyHas, + FillsInWebApp, Gets, GetsCreatedVia, Has, @@ -27,9 +31,16 @@ HasSavedAs, IsAnAuthenticatedUser, OfLength, + OpensWebApp, + PressesInWebApp, ResultHas, ResultingIsSavedAs, Save, + SavesTextInWebAppAs, + SeesEnabledInWebApp, + SeesInWebApp, + SeesWebAppAt, + SelectsInWebApp, SharedContext, State, TaskCompletes, @@ -165,6 +176,43 @@ rf'(?: with (?P{ASSERT_CLAUSES}))?$' ) RESULT_HAS = rf'the result has (?P{ASSERT_CLAUSES})$' + +# The web app's steps: what a named user does in it and sees in it. +USER = r'"(?P[^"]*)"' +# What an element may be, as a step writes it: each of `Element.Role` +# in lower case, so that the proto is the one list. +ROLES = tuple( + name.lower() + for name, number in Element.Role.items() + if number != Element.Role.ROLE_UNSPECIFIED +) +ELEMENT = rf'the "(?P[^"]*)" (?P{"|".join(ROLES)})' +WEB_APP = 'in the web app' +OPENS_WEB_APP = rf'{USER} opens the web app(?: at "(?P[^"]*)")?$' +CLICKS_IN_WEB_APP = rf'{USER} clicks {ELEMENT} {WEB_APP}$' +FILLS_IN_WEB_APP = ( + rf'{USER} fills "(?P
-