45 lines
5.2 KiB
Markdown
45 lines
5.2 KiB
Markdown
# FlowSpec (declarative UI scenarios)
|
|
|
|
**Status: RECOMMENDED, and the expected form when a UI ships in more than one renderer.** A UI flow is described once, as a declarative scenario, and replayed against every UI implementation. The scenario is the single source of truth for what a flow does; the implementations cannot drift in their behavior because they all run the same steps. The engine is the public package [FlowSpec](https://codeberg.org/Mihaela/FlowSpec).
|
|
|
|
## The model
|
|
|
|
- A **scenario** is a JSON file: `{ id, title, actor?, preconditions[], tags[], steps[] }`.
|
|
- A **step** is `{ verb, target, arg? }`. Its key is `"verb:target"`.
|
|
- A **verb** comes from a small closed vocabulary (`open`, `tap`, `type`, `swipe`, `wait`, `assert`, `request`, `ambient`, `rotate`). `ambient` injects an automatic, no-user event (a timer, a notification, an agent's progress) at its true position in the ordered run; `rotate` turns the device to `portrait` or `landscape`. The vocabulary is deliberately tiny so a scenario reads like a specification, not a script; adding a verb is a coordinated change across the schema, the enum, and every registry.
|
|
- A **step registry** is the seam: one per UI, a `@MainActor` type with a single `execute(_ step: Step) throws` that turns a step into a real action against that UI. The registry throws on an unknown `verb:target`, so a scenario can never reference machinery that is not wired up. FlowSpec ships a default identifier-driven XCUITest registry in its `FlowSpecXCUI` module (see `pom.md` for the page-object pattern it drives); the headless registry that drives the model layer you write yourself. When the thing a step drives is asynchronous (a pre-UI engine whose one door is `async`), the registry is an `AsyncStepRegistry` run by `AsyncScenarioRunner`, so the replay awaits the engine's real concurrency instead of reducing it to a blocking call. The core `FlowSpec` engine stays `Foundation`-only.
|
|
- The **runner** walks a scenario's steps in order, stops on the first failure, and reports the scenario id and step index, so a log points at the exact step.
|
|
|
|
## Why and when
|
|
|
|
- **One flow, many UIs.** When a feature ships in more than one renderer (SwiftUI, AppKit, UIKit; see `pre-ui-layer.md`), write the flow once and bind a registry per renderer. The renderers cannot disagree about what the flow does, because the steps are shared.
|
|
- **The same corpus, headless and on-device.** The engine is `Foundation`-only and transport-agnostic. The same scenario corpus runs on-device through an XCUITest registry that drives page objects (`pom.md`), AND headless through a registry that drives the model layer directly (`pre-ui-layer.md`). This is the strongest form of the pre-UI-layer's headless-parity invariant: do not keep a separate set of hand-written headless tests that can drift from the device scenarios; replay the one corpus both ways.
|
|
- **A closed vocabulary keeps scenarios honest.** Because the verb set is small and the registry throws on an unwired step, a scenario stays a readable specification and cannot silently no-op.
|
|
|
|
## DO
|
|
|
|
- Keep the flow in a scenario file; keep the per-UI mechanics in a step registry, one per renderer.
|
|
- Drive the on-device registry through page objects (`pom.md`), not raw queries.
|
|
- Replay the same scenario corpus headless against the model layer; do not fork a separate headless test set.
|
|
- Let the runner stop at the first failure and report the scenario id and step index.
|
|
- Load a hand-edited scenario with `ScenarioLoader.loadValidated`, which also rejects an inconsistent one (no steps, a `swipe` with no direction, a `wait` with a non-positive timeout, a `rotate` to no orientation) with every finding named by its rule code; `load` only decodes.
|
|
- Throw `unknownStep` on an unwired `verb:target` so a scenario cannot reference missing machinery.
|
|
|
|
## DON'T
|
|
|
|
- Do not embed UI-framework calls in the scenario or the engine; the engine is `Foundation`-only and the framework binding lives in the registry.
|
|
- Do not grow the verb vocabulary casually; a wide verb set turns a scenario back into a script.
|
|
- Do not maintain device scenarios and headless tests as two separate corpora that drift; share one.
|
|
- Do not let a step silently pass when its handler is missing; the registry throws.
|
|
|
|
## Acceptance check
|
|
|
|
A conforming setup has: (1) flows expressed as scenario files, not procedural test code; (2) one `StepRegistry` per renderer, with the UI-framework binding confined to it and the engine `Foundation`-only; (3) the on-device registry driving page objects, not raw element queries; (4) the same scenario corpus replayed headless against the model layer when the UI has a pre-UI layer; (5) an unwired `verb:target` throwing `unknownStep`, verified by a test; (6) failures carrying scenario id and step index. A project that keeps its device scenarios and its headless coverage as two unrelated corpora fails the headless-parity intent of this rule.
|
|
|
|
## Companion rules
|
|
|
|
- `pre-ui-layer.md`: the headless registry drives the model layer; one corpus replayed headless and on-device is the strongest headless-parity form.
|
|
- `pom.md`: the on-device registry maps each step to a page-object action.
|
|
- `../core/testing-discipline.md`: real tests, on every change.
|
|
- `../core/no-shortcuts-first-principles.md`: an unwired step throws rather than silently passing.
|