English · 日本語
Scenario specification (authoring reference)¶
A scenario is Bajutsu's only persisted artifact: plain YAML, version-controlled in git and reviewable in a PR. record (AI) writes it the first time; humans own and edit it afterward. run executes this structure without AI.
Implementation: bajutsu/scenario/ (pydantic models under models/, extra="forbid" rejects unknown keys).
The normative grammar — every production, type, default, and validation rule — is in dsl-grammar. This page is the authoring guide: how to write a scenario, by example.
Related: cookbook (worked examples) · dsl-grammar (formal grammar) · selectors (how selectors and assertions evaluate) · evidence · run-loop (execution)
File shape¶
One file = a list of scenarios, or a { description, scenarios } mapping when you want a
file-level description. load_scenarios() accepts either form; a top level that is neither is
rejected.
- name: ... # scenario 1
steps: [...]
- name: ... # scenario 2
steps: [...]
With a file-level description (and an optional per-scenario description):
description: What this file covers.
scenarios:
- name: ...
description: What this scenario checks.
steps: [...]
Both the file description and each scenario's description appear in report.html (the
summary header and each scenario card) and in the bajutsu serve UI.
Schema version¶
The mapping form may carry a top-level schema integer marking the scenario schema version. A file
that omits it is treated as version 1, so every existing scenario is valid unchanged:
schema: 1
scenarios:
- name: ...
steps: [...]
When a scenario declares a schema newer than the running bajutsu understands, the load fails
with a clear upgrade-path message instead of an opaque "unknown field" error — the case that arises
once a scenario tree is read across versions (for example, a config sourced from a pinned Git ref).
The current version is SCHEMA_VERSION in bajutsu/scenario/models/scenario.py. Bump it only for a
load-breaking change — removing a required field's meaning, or a change an older bajutsu would
misinterpret rather than merely reject; a purely additive optional field needs no bump.
Top-level structure (Scenario)¶
| Key | Type | Default | Description |
|---|---|---|---|
name |
str | required | Scenario name (used for the report / JUnit testcase / codegen method name) |
description |
str | none | Optional human description; shown on the scenario's report card and in the serve UI |
from |
str | none | Provenance — the natural-language goal record authored this scenario from (provenance). Authoring metadata only; run ignores it |
tags |
list[str] | [] |
Selection labels; the CLI --tag / --exclude flags pick which scenarios run (reuse, data, and tags) |
data / dataFile |
list / str | none | Data-driven rows — inline data, or dataFile (a CSV path). Expands into one run per row, substituting ${row.col}. Mutually exclusive (reuse, data, and tags) |
preconditions |
object | {} |
Per-test environment setup (below) |
steps |
list | required | The ordered actions (below) |
expect |
list | [] |
Final assertions after all steps pass (selectors) |
capturePolicy |
list | [] |
Repeatedly-firing evidence rules (evidence) |
network |
object | none | { filter: { domains: [...] } } — filter.domains scopes which observed requests are interleaved into the report's Steps timeline (by URL host; a parent domain matches subdomains). Unset shows all; the Network tab always lists them all (reporting) |
mocks |
list | [] |
Deterministic network stubs — a matching outgoing request gets a canned response instead of hitting the network (network mocks) |
redact |
object | none | Masking applied before evidence is written (evidence) |
dismissAlerts |
bool / object | none (on) | The vision alert guard — clears OS prompts idb cannot see. On by default; false disables it, { instruction: "tap Allow" } keeps it on but taps a named button. CLI --dismiss-alerts/--no-dismiss-alerts overrides (below) |
permissions |
dict | {} |
Declarative OS permission state — { <service>: grant \| revoke } — applied before the app launches (below) |
- name: filter narrows the catalog
preconditions:
launchEnv: { SHOWCASE_UITEST: "1" }
steps:
- tap: { label: "Search", traits: [button] }
- wait: { for: { id: search.field }, timeout: 10 }
- type: { text: "Horse 3", into: { id: search.field } }
- wait: { for: { id: search.row.3 }, timeout: 5 }
expect:
- count: { sel: { idMatches: "search.row.*" }, equals: 1 }
- value: { sel: { id: search.count }, equals: "1" }
(real file: demos/showcase/scenarios/search.yaml)
preconditions (environment setup)¶
Implementation: scenario/models/scenario.py Preconditions. The runner's launch_driver reads this to build
the launch sequence (run-loop).
| Key | Type | Default | Description | Wired |
|---|---|---|---|---|
erase |
bool | false |
Wipe the whole simulator (simctl erase — apps/data/settings) before the test. Off by default; reinstall keeps the app fresh without a full wipe, so set true only when a test needs a pristine device |
✅ |
reinstall |
clean | overwrite |
clean |
How the app is reinstalled before each run when the app config sets appPath: clean = uninstall then install (fresh app + data); overwrite = install over the existing app (keeps its data) |
✅ |
launchArgs |
list[str] | [] |
Launch arguments (appended to config's launchArgs) |
✅ |
launchEnv |
dict | {} |
Launch env (injected via SIMCTL_CHILD_*; merged onto config's launchEnv) |
✅ |
deeplink |
str | none | Opened after launch via simctl openurl |
✅ |
locale |
str | none | Force the locale/language at launch (-AppleLocale/-AppleLanguages); overrides the app/config default |
✅ |
setup |
str | none | A reusable prelude scenario file (resolved relative to this scenario); its steps run before this scenario's own | ✅ |
launchEnv resolution order is config's
launchEnv< preconditions'launchEnv(the one closer to the test wins).launch_drivermerges{**eff.launch_env, **pre.launch_env}.
dismissAlerts (the system-alert guard)¶
idb cannot see or tap SpringBoard-level prompts (iOS "Save Password?", a permission request, "Allow Paste"). These prompts cover the app and collapse its element tree, silently blocking a step. The alert guard is a vision-based fallback (alerts.py): when a step is blocked, it takes a screenshot, asks Claude where to tap, clears the prompt, and retries the step once (details). For a wait step (for/settled/screenChanged), the guard also watches the already-polled screen and fires mid-wait the moment the tree looks collapsed (debounced, cooldown-limited, capped at two attempts per wait) — recovering before the wait's own timeout elapses, rather than waiting for the step to fail first (BE-0269).
It is on by default and fires only when a step (or expect) is blocked, or — for a guarded wait — the polled screen looks blocked, so a passing scenario never calls the model. It requires ANTHROPIC_API_KEY; without one it no-ops and the run continues unaffected. Use dismissAlerts to change the behavior per scenario:
| Form | Meaning |
|---|---|
| (omitted) | on; tap the least-destructive button ("Not Now" / "Don't Allow" / "Cancel") |
dismissAlerts: false |
off for this scenario |
dismissAlerts: { instruction: "tap Allow" } |
on, but tap the button the instruction names — e.g. to grant a permission |
dismissAlerts: { enabled: false } |
off (the explicit object form of false) |
- name: grant notification permission
dismissAlerts: { instruction: "tap Allow" } # accept the prompt instead of dismissing it
steps:
- tap: { id: sys.requestNotif }
- wait: { for: { id: sys.notif.authorized }, timeout: 4 } # the guard taps Allow, then this passes
The CLI --dismiss-alerts / --no-dismiss-alerts flag overrides every scenario (otherwise the
per-scenario default applies); --alert-instruction sets a default button instruction that a
scenario's own instruction overrides. (real file:
demos/showcase/scenarios/permission.yaml)
permissions (pre-launch permission state)¶
dismissAlerts reacts to a permission prompt only after it appears, and only by tapping it —
useful when the prompt is unexpected, but it cannot revoke a permission or guarantee the app
starts from a known state. When the permission is known ahead of time, permissions sets it
before the app process starts, so the prompt never appears at all: a deterministic,
machine-checkable device mutation with no model call
(BE-0276).
- name: profile — camera already granted
permissions:
camera: grant
location: grant
contacts: revoke
steps:
- tap: { id: profile.avatar.upload } # no camera-permission prompt — already granted
Each entry is <service>: grant | revoke, where <service> is one of a small backend-agnostic
vocabulary: location, camera, microphone, contacts, photos, calendar, notifications.
Each backend maps a service to its own native mechanism:
- iOS drives
simctl privacy <udid> <grant|revoke> <tcc-service> <bundle>— the same TCC (Transparency, Consent, and Control) database SpringBoard's permission prompts read. - Android drives
pm grant/pm revoke, reusing the plumbing behind the config-levelgrantPermissionslist (drivers); a scenario'spermissionslayers on top of that config-level default and can revoke what it grants.
iOS has no TCC service for notifications (iOS notification authorization is not part of
TCC), so a scenario naming notifications on an iOS target fails preflight — before any device
work, naming the unsupported capability; dismissAlerts remains the reactive path for that one
prompt. Android's POST_NOTIFICATIONS is a runtime permission (API 33+), so Android supports the
whole vocabulary. Every other unsupported combination (a service unsupported on the chosen backend)
fails preflight the same way, named individually.
permissions has no app-level XCUITest / Espresso equivalent, so codegen emits a labeled
// TODO per service rather than generating code for it — bajutsu applies the field itself, before
the generated test's own launch step.
Selectors (addressing an element)¶
A selector identifies which element to act on or assert against. Provide one or more fields; multiple fields are AND-ed (all must hold), and at least one is required. How a selector resolves to exactly one element, and why an ambiguous selector fails instead of picking the first match, is covered in selectors. The formal shape is in dsl-grammar.
| Field | Type | Description |
|---|---|---|
id |
str | list[str] | Exact accessibilityIdentifier — first choice (stable, non-localized). A list is an OR of candidates: the element's id must equal any one |
idMatches |
str | list[str] | Glob over the id (e.g. "list.row.*"; assumes multiple matches). A list matches if the id matches any glob |
label |
str | Exact accessibilityLabel (visible text) — auxiliary / disambiguation |
labelMatches |
str | Regex / substring over the label (re.search) |
traits |
list[str] | Narrow by accessibility trait (subset test, e.g. [button]) |
value |
str | Exact accessibility value |
within |
Selector | Scope to a container — the match must sit inside an element the nested selector resolves to (nestable) |
index |
int | Pick the k-th of multiple matches (negatives allowed) — last resort, order-sensitive |
- tap: { id: counter.increment } # by id (recommended)
- tap: { id: [stable.refresh, stable_refresh] } # OR of id candidates (see below)
- tap: { label: "Delete" } # by visible label (e.g. an alert button)
- tap: { id: row.action, within: { id: list.row.3 } } # scoped to a container's subtree
- tap: { labelMatches: "^Item ", traits: [button], index: 0 } # first matching button, fields AND-ed
Prefer
id. For a set of elements (count / existence) useidMatches. Useindexonly as a last resort — it breaks when order changes. Full resolution semantics: selectors.
Cross-platform ids: a candidate list (BE-0221)¶
A scenario is shared across platforms only to the extent its selectors are by id, and the driver decides which app-side attribute satisfies that id. But some platforms can't reproduce the SPEC id verbatim: Android's android:id (the Views toolkit) allows neither . nor -, so stable.refresh surfaces as stable_refresh and search.results-empty as search_results_empty. To keep one scenario running unchanged everywhere, give id / idMatches a list of candidates and the match becomes an OR over them:
- wait: { for: { id: [stable.refresh, stable_refresh] }, timeout: 10 }
- count: { sel: { idMatches: [stable.row.*, stable_row_*] }, equals: 5 }
The dotted form matches on iOS and Android Compose (which reproduce it verbatim); the underscore form matches on Android Views. Only one form is ever on screen for a given app, so the selection stays deterministic: if both candidate forms happened to be present at once, the selector is ambiguous and fails fast — an OR never turns a two-or-more match into a silent pick. The candidate list keeps the id convention explicit in the scenario, rather than a hidden driver-side .↔_ rewrite that could conflate distinct ids. The showcase's shared scenarios use this so showcase-swiftui / showcase-compose / showcase-views all run the same files.
Step grammar (steps)¶
Each step is exactly one action + optional modifiers (capture: / name:). Two or more
actions in one step is a validation error (scenario/models/steps.py _one_action).
| Action | Form | Description |
|---|---|---|
tap |
tap: <Selector> |
requires unique resolution (fails if ambiguous) |
doubleTap |
doubleTap: <Selector> |
two quick taps on the resolved element |
longPress |
longPress: { sel: <Selector>, duration: <sec> } |
long press |
type |
type: { text: "...", into?: <Selector>, submit?: <bool> } |
with into, focuses first |
clear |
clear: { into: <Selector> } |
focus the field and remove its entire current content; web context raises |
delete |
delete: { into: <Selector>, count: <int> } |
focus the field and delete count characters from the end (count > 0); web context raises |
select |
select: { into: <Selector>, mode?: "all" } |
focus the field and select its content (mode default all); idb / web context raise — codegen routes to XCUITest |
copy |
copy: {} |
copy the active selection to the clipboard; requires a prior select; idb / web context raise |
selectOption |
selectOption: { sel: <Selector>, option: "..." } |
set a web <select> to the option with this value; web only (iOS / Android raise) |
swipe |
swipe: { on: <Selector>, direction: up\|down\|left\|right } or swipe: { from: [x,y], to: [x,y] } |
selector form and coordinate form cannot mix; the directional form scrolls |
drag |
drag: { on: <Selector>, direction: up\|down\|left\|right, amount?: <frac> } |
a real pointer drag of the element (a handle / divider / slider), not a scroll |
pinch |
pinch: { sel: <Selector>, scale: <num> } |
two-finger magnify; scale > 0 (>1 zooms in, <1 out) |
rotate |
rotate: { sel: <Selector>, radians: <num> } |
two-finger rotation; >0 is clockwise |
wait |
wait: { for\|until: ..., timeout: <sec> } |
condition wait (below) |
assert |
assert: [ <Assertion>... ] |
mid-step verification |
relaunch |
relaunch: { env?: {...}, args?: [...] } |
terminate + relaunch the app (re-applying launch env/args, plus the given overrides), then wait until ready |
setLocation |
setLocation: { lat: <num>, lon: <num> } |
override the simulated GPS location (simctl location set) |
push |
push: { payload: {...} } |
deliver a simulated push notification (simctl push) with this APNs (Apple Push Notification service) payload |
http |
http: { method?, url, headers?, body?, status?, saveBody? } |
issue an HTTP request (test-data setup / webhook / API); checks status, stores the body as ${vars.<saveBody>} |
totp |
totp: { secret, into: { var } } |
generate an RFC 6238 time-based one-time password (2FA) locally into ${vars.<var>} |
email |
email: { match: { to?, subject?, subjectMatches? }, extract: { var, bodyMatches }, timeout } |
poll the configured mailbox until a matching message arrives, extract a code into ${vars.<var>} |
manual |
manual: { label: "...", bypass?: "..." } |
a human takeover recorded during record (BE-0185); has no deterministic run-time equivalent, so it fails loudly at run time — never a silent pass |
background |
background: {} |
send the app to the background (Home button) |
foreground |
foreground: {} |
resume a backgrounded app (simctl launch, no settle sleep) |
clearKeychain |
clearKeychain: {} |
reset the Simulator keychain (saved passwords / certificates) |
clearClipboard |
clearClipboard: {} |
clear the Simulator pasteboard |
setClipboard |
setClipboard: { text: "..." } |
seed the Simulator pasteboard for a paste flow |
overrideStatusBar |
overrideStatusBar: { time?, batteryLevel?, batteryState?, cellularBars?, wifiBars? } |
override the status bar for deterministic screenshots |
clearStatusBar |
clearStatusBar: {} |
remove status-bar overrides (restore the live bar) |
use |
use: { component: <file>, with?: {...} } |
expand a reusable component's steps — a compile-time macro (reuse) |
Modifiers:
capture: [<token>...]— evidence for this step only (evidence).name: <str>— the step id (the evidence output directory name · report label). Defaults tostep<i>.from: <str>— provenance (below): the phrase this step was recorded from. Authoring metadata;runignores it.
tap¶
- tap: { id: counter.increment } # exact id (recommended)
- tap: { label: "Delete" } # exact label (for an in-app alert etc. with no id)
type¶
- type: { text: "a@b.com", into: { id: auth.email } } # focus, then type
- type: { text: "hello", submit: true } # submit appends a newline / confirm (uses current focus)
Internally, when
intois given, the target istapped beforetype_text(orchestrator/actions/_do_action).On idb (iOS Simulator), typing non-Latin text or emoji falls back to a paste. idb's hardware-keyboard path only encodes the US keyboard layout. The fallback leaves the typed text on the Simulator's pasteboard. A later
clipboardassertion in the same scenario sees that text, not the prior value.
selectOption¶
- selectOption: { sel: { id: nav.theme-picker }, option: midnight } # set the <select> to the option whose value is "midnight"
For a native HTML <select>, whose dropdown is not part of the page's element tree, a coordinate
tap cannot switch the value deterministically. selectOption resolves the <select> through the
same unique-match core every action uses, then sets the option by its value (not its visible
label) and fires a change event, so the page reacts exactly as it would to a user's pick. The
value matches what a value assertion reads back from the <select>, so a selection is directly
assertable. selectOption is a web-only action — a <select> has no native counterpart on iOS or Android,
so those backends fail the step with a clear "unsupported action" reason rather than doing nothing.
swipe¶
- swipe: { on: { id: comp.swipearea }, direction: left } # frame center → a screen fraction in a direction (default 0.125)
- swipe: { from: [100, 400], to: [100, 200] } # raw coordinates (last resort)
{on,direction} and {from,to} must be exactly one or the other (mixing or omitting a side
is a validation error).
The directional form means "scroll", and each backend realizes it with the primitive that
actually scrolls: a real OS drag on iOS / Android, and — since a mouse drag does not scroll a web
page — a wheel event (desktop) or a touch drag (a mobile deviceMode)
on web (BE-0227). The coordinate form is a literal pointer drag for its own sake (a canvas / map
pan / drag handle), the same raw-drag last resort on every backend.
drag¶
- drag: { on: { id: replay.divider }, direction: right } # drag a grabbed handle
- drag: { on: { id: volume.slider }, direction: up, amount: 0.3 } # ... a fraction of the screen
drag is an element-anchored pointer drag — it grabs the element and moves it in a direction,
for a resize divider, a slider thumb, a reorder handle: any control you drag rather than scroll. It
shares swipe's directional geometry (amount is a fraction of the screen, 0 < amount ≤ 1;
omitted, a small default), but where a directional swipe scrolls, drag performs a genuine
pointer drag. The distinction only bites on web: there a directional swipe is a wheel scroll that
would leave a grabbed handle unmoved, so use drag for it; on iOS / Android a real OS drag both
scrolls and moves handles, so the two coincide.
doubleTap / pinch / rotate (gestures)¶
- doubleTap: { id: gest.doubletap } # two quick taps
- pinch: { sel: { id: gest.pinch }, scale: 2.0 } # >1 zooms in, 0<scale<1 zooms out
- rotate: { sel: { id: gest.rotate }, radians: 1.57 } # >0 clockwise (radians)
scale must be > 0 (a validation error otherwise). pinch / rotate require multi-touch: on the idb backend they fail with a "needs multiTouch" reason. Their primary target is the generated XCUITest (pinch(withScale:) / rotate(_:)). doubleTap runs on idb (two taps). (real file: demos/showcase/scenarios/gestures.yaml)
wait (condition wait)¶
Fixed sleeps are not supported. timeout is mandatory (no infinite waits).
- wait: { for: { id: home.title }, timeout: 5 } # until an element appears
- wait: { until: { gone: { id: home.spinner } }, timeout: 15 } # until an element disappears
- wait: { until: screenChanged, timeout: 5 } # until query() changes
- wait: { until: settled, timeout: 3 } # until the screen stops changing
- wait: { until: { request: { method: GET, path: /items, status: 200 } }, timeout: 8 } # until a matching request is observed
for and until are exclusive (only one). until is screenChanged / settled /
{ gone: <Selector> } / { request: <RequestMatch> }. The request form polls the network
collector (evidence, the --network run flag) until at least one observed exchange
matches (same matcher as the request assertion: method / url /
urlMatches / path / pathMatches / status / bodyMatches, all AND-ed; count raises the
threshold). The endpoint is pinned by url
(exact full URL) or urlMatches (regex/substring), or just path. Timeout handling differs by kind
(run-loop): for / gone / screenChanged / request
time out = step failure; settled is a stabilization hint, so a timeout just proceeds with the
current screen (it does not fail).
assert (mid-step verification)¶
Verification mid-step. The DSL is the same as expect (next section).
- assert:
- disabled: { id: auth.submit }
setLocation / push (device control)¶
- setLocation: { lat: 35.681, lon: 139.767 } # simctl location set
- push: { payload: { aps: { alert: "You have mail" } } } # simctl push (APNs payload)
Both drive the Simulator via simctl and need a per-device control channel, so they are unavailable on
the fake driver and in parallel runs — there the step fails cleanly (it does not crash). push delivers
its payload as the APNs JSON to the app under test.
http (request, for test-data setup)¶
- http: { method: POST, url: "https://api.test/seed", body: '{"n":1}', status: 200 } # fails if status != 200
- http: { url: "https://api.test/token", saveBody: token } # vars.token ← response body text
- assert:
- exists: { id: home.title }
http issues the request from the runner over HTTP — it does not go through the UI driver — so a
status mismatch fails the step, and saveBody stores the response body text as ${vars.<name>} for
later steps. Touching no device, it is the one device-independent action here.
totp (two-factor one-time password)¶
- totp: { secret: "${secrets.TOTP_SEED}", into: { var: code } } # vars.code ← current 6-digit OTP
- type: { text: "${vars.code}", into: { id: auth.code } }
totp computes an RFC 6238 time-based one-time
password locally — from the shared secret (base32; keep it in ${secrets.*}, not in the YAML) and
the current time — and stores the current code in ${vars.<var>} for a later type / assert.
This automates a 2FA sign-in without a scripting escape hatch or an LLM: the value is a deterministic
function of the secret and the clock (BE-0046).
email (poll a mailbox for a received code)¶
- email:
match: { to: "test@example.com", subjectMatches: "verification" } # which message to wait for
extract: { var: code, bodyMatches: "[0-9]{6}" } # vars.code ← first capture group
timeout: 30
- type: { text: "${vars.code}", into: { id: auth.otp } }
email waits for a 2FA / verification code delivered by email: it polls a generic HTTP mailbox
(configured under targets.<name>.mailbox, see configuration)
until a message that arrived after the step started satisfies match, then extracts the value
from its body by the bodyMatches regex (first capturing group, or the whole match) into
${vars.<var>}. The wait is a condition wait with a mandatory timeout (no fixed sleep): a
timeout, a matched message whose body the regex can't hit, or an unreachable / non-2xx mailbox is a
clean step failure — never a silent wrong value. Only mail newer than the step's start counts (keyed
on message id, so a stale code from an earlier run is never matched), and among new matches the
newest wins. Deterministic and LLM-free; the endpoint and credentials live in config-referenced
${secrets.*}, so the scenario stays app-agnostic (BE-0046).
manual¶
A human takeover recorded during record.
- manual: { label: "solve the login CAPTCHA" } # no deterministic equivalent (a real CAPTCHA)
- manual: { label: "grant Face ID", bypass: "device-control biometric match (BE-0052)" } # names the bridge an author could wire
record emits a manual step when a blocker is an operation the AI cannot perform — a CAPTCHA, a
biometric prompt, a gesture the agent repeatedly fails to resolve. The human operates the live device
and hands control back (the acted handoff, recording);
the step records a marker of the observed transition, not the raw gesture. bypass, when set, names
the test-build flag or the device-control / device-state primitive (BE-0035 / BE-0052) an author could
wire to make the step replayable; omitted, it marks a takeover with no such equivalent (a real CAPTCHA).
Every codegen target renders it as a labeled // TODO. A manual step is never a silent pass: it
has no deterministic run-time equivalent, so at run time it fails loudly with ManualStepRequired,
surfacing label and the bypass hint (directives 1 and 2). Wiring the named bypass — then replacing
the manual step with the deterministic action — is the author's path to a replayable scenario (BE-0185).
Device & system control (iOS)¶
- background: {} # Home button (backgrounds via SpringBoard, no terminate)
- foreground: {} # resume the backgrounded app (simctl launch)
- clearKeychain: {} # reset saved passwords / certificates
- clearClipboard: {} # clear the pasteboard
- setClipboard: { text: "COUPON123" } # seed the pasteboard (paste flows)
- overrideStatusBar: { time: "9:41", batteryLevel: 100, wifiBars: 3 } # freeze the status bar
- clearStatusBar: {} # restore the live status bar
Like setLocation / push, these drive the Simulator via simctl, so they need a per-device control
channel and fail cleanly on the fake driver / in parallel runs. overrideStatusBar is most useful right
before a screenshot or a visual assertion, to freeze the clock and signal bars for a stable image.
background / foreground are the two halves of a background/foreground transition; foreground
resumes the app without any settle sleep, so wait for a concrete element afterward if you need one.
setClipboard seeds the pasteboard for a paste flow (BE-0052).
Assertion DSL¶
Shared by expect (final verification) and assert (mid-step). Items in the list are all
AND-ed; one failure fails the step. The evaluation mechanics (element resolution, comparison)
are in selectors.
| Assertion | Meaning | Example |
|---|---|---|
exists |
a matching element exists (negate: true checks absence) |
exists: { id: home.title } / exists: { id: settings.banner, negate: true } |
value |
accessibility value match | value: { sel: { id: counter.value }, equals: "2" } |
label |
label exact / substring / regex | label: { sel: { id: settings.status }, contains: "done" } |
count |
number of matching elements | count: { sel: { idMatches: "list.row.*" }, equals: 5 } |
enabled / disabled |
actionable or not (the notEnabled trait) |
disabled: { id: auth.submit } |
selected |
selected / toggled state (the selected trait) |
selected: { id: tab.home } |
request |
a matching network exchange was observed (needs --network) |
request: { method: POST, path: /login, status: 200, count: 1 } |
event |
an analytics / telemetry event was sent — endpoint + JSON body fields, with a count (needs --network) |
event: { url: "https://t.example.com/track", body: { name: purchase_completed }, count: { equals: 1 } } |
requestSequence |
matchers were observed in this order (needs --network) |
requestSequence: [ { urlMatches: "/auth/refresh" }, { urlMatches: "/api/account" } ] |
responseSchema |
a captured response body conforms to a JSON Schema (needs --network) |
responseSchema: { request: { urlMatches: "/api/items" }, schema: items.json } |
visual |
the screen matches a baseline image (visual regression) | visual: { baseline: home.png, threshold: 0.02 } |
clipboard |
the device pasteboard matches (read back via simctl pbpaste) |
clipboard: { equals: "COUPON123" } / clipboard: { matches: "\\d{6}" } |
existswrites its selector inline ({ id: ... }directly).negateis optional.value/labeltakesel:+ exactly one ofequals/contains/matches.counttakessel:+ exactly one ofequals/atLeast/atMost.enabled/disabled/selectedtake a selector inline.requestmatches an observed network exchange (details below); needs the--networkrun flag.eventmatches an analytics / telemetry event the app sent (details below); needs the--networkrun flag.requestSequencechecks a list of request matchers were observed in order (details below); needs the--networkrun flag.responseSchemavalidates a captured response body against a JSON Schema (details below); needs the--networkrun flag.visualpixel-compares a screenshot against a baseline image (details below).clipboardreads the device pasteboard (simctl pbpaste) and checks exactly one ofequals/matches(regex) — the read-back half ofsetClipboard, for verifying a "copy" action. It needs the per-device control channel, so it is unavailable on the fake driver / in parallel runs and fails cleanly there (BE-0052). On idb, a preceding non-Latintypealso leaves its text there — see thetypesection above.
Locale caveat: string comparisons on
label/valueand assertions that look at visible text break under translation. Write these against config's fixed locale, and write the selector itself byid.
request (network assertion)¶
request asserts that the run's network collector observed a matching HTTP exchange (needs the
--network run flag and BajutsuKit in the app). The same matcher backs the until: { request: ... }
wait and mocks (below). At least one match field is required; the listed fields are AND-ed.
| Field | Type | Description |
|---|---|---|
method |
str | HTTP method (GET, POST, …) |
url |
str | Exact full URL (the endpoint) |
urlMatches |
str | Regex / substring over the URL (query strings live here) |
path |
str | Exact path (query ignored) |
pathMatches |
str | Regex over the path |
status |
int | Response status code |
bodyMatches |
str | Regex / substring over the request body |
count |
int | Number of matching exchanges — exact for the assertion, a lower bound for the wait |
- assert:
- request: { method: POST, path: /login, status: 200, count: 1 }
- request: { urlMatches: "/search", bodyMatches: "apple" } # match on the request body
countis not a match field — at least one ofmethod/url/urlMatches/path/pathMatches/status/bodyMatchesmust be present. (real file:demos/showcase/scenarios/network_mock.yaml)
event (analytics event assertion)¶
event asserts on a behavior the screen never shows: an analytics / telemetry event the app sent
(BE-0048).
It is a pure check over the same observed exchanges request reads (needs the --network run flag),
so the verdict stays machine-only — no LLM. It filters the timeline by the event's endpoint (the
same method / url / urlMatches / path / pathMatches matcher as request), then by structured
request-body fields, and checks how many exchanges survive against a count operator.
| Field | Type | Description |
|---|---|---|
method / url / urlMatches / path / pathMatches |
str | Endpoint matcher (AND-ed), same meaning as request |
body |
map | Each key: value must be present in the JSON request body and equal the value, compared as text (so amount: "300" matches the JSON number 300; a JSON boolean / null matches "true" / "false" / "null") |
count |
object | Expected multiplicity — exactly one of equals / atLeast / atMost. Omitted means at least one |
expect:
# the purchase event fired exactly once with the right amount
- event:
url: "https://t.example.com/track"
body: { name: purchase_completed, amount: "300" }
count: { equals: 1 }
At least one of an endpoint field or
bodymust be present, so an event always pins something. A non-JSON, non-object, or absent request body matches nobodycriterion (it fails rather than guessing). Body values support${vars.*}/${secrets.*}tokens like the rest of the DSL.
requestSequence (ordered requests)¶
requestSequence asserts that several requests happened in a given order — e.g. a token refresh
before the protected call (BE-0048).
It is a pure check over the observed timeline (needs the --network run flag), so the verdict stays
machine-only. It takes a non-empty list of request matchers (the same
fields) and matches them as an ordered subsequence: each matcher must match a distinct exchange at
a strictly later position than the previous one. Unrelated traffic may interleave between them, so
the check is robust to noise; listing the same matcher twice requires two occurrences in order.
expect:
- requestSequence:
- { method: POST, urlMatches: ".*/auth/refresh" }
- { method: GET, urlMatches: ".*/api/account" }
Each matcher uses the same fields as
request(method/url/urlMatches/path/pathMatches/status/bodyMatches); a matcher's owncountis ignored here, since the sequence's job is order. For a pure multiplicity check, userequestwithcount.
responseSchema (JSON Schema of a response)¶
responseSchema asserts that a captured response body conforms to a JSON Schema — a contract
check the screen can't express (BE-0048).
It is a pure, deterministic check over the observed timeline plus a stored schema file (needs the
--network run flag), so the verdict stays machine-only. request selects the exchange (the same
matcher fields) whose response is validated; schema is a file path resolved within the target's
schemas directory (--schemas flag, config targets.<name>.schemas, or schemas/ beside the
scenario). Validation uses the jsonschema library — install the schema extra
(pip install bajutsu[schema]).
expect:
- responseSchema:
request: { method: GET, urlMatches: ".*/api/items" }
schema: items.json # resolved within the schemas dir
It validates the first matching exchange's response. It fails (rather than guessing) when no exchange matches, the schema file is missing, the response has no body or isn't JSON, or the body doesn't conform. The schemas dir resolves like
--baselinesforvisual.
visual (visual regression)¶
- assert:
- visual: { baseline: "home.png", threshold: 0.02, exclude: [{ x: 0, y: 0, w: 390, h: 47 }] }
- visual: { baseline: "detail.png", compare: pixelmatch, colorTolerance: 0.1, antialiasing: true }
- visual: { baseline: "summary-card.png", element: { id: "summary-card" } } # one element only
- visual: { baseline: "home.png", exclude: [{ selector: { label: "last updated" } }] } # mask by element
visual captures a screenshot and compares it against baseline (a PNG resolved inside the run's
baselines dir — --baselines, or baselines/ beside the scenario).
The comparison engine is selectable via compare (BE-0165):
| Engine | Description | Default |
|---|---|---|
exact |
Pixel-perfect — any channel difference counts as a changed pixel. | Yes (backward-compatible) |
pixelmatch |
Perceptual YIQ color distance with anti-aliasing detection. Tolerates sub-pixel rendering noise and one-pixel edge shifts. | No |
When compare is omitted, the engine falls back to the target's visualCompare config
(under defaults: or targets.<name>), and then to exact.
threshold is the allowed percentage of differing pixels (default 0.0 = exact match), shared
by all engines. colorTolerance (0–1, default 0.1) sets the per-pixel perceptual color
tolerance for pixelmatch; antialiasing (default true) discounts anti-aliased pixels from
the diff. exclude masks regions before comparing, e.g. a status bar or a clock. Each entry is
either a rectangle in screenshot pixels ({ x, y, w, h }) or a { selector: <Selector> }
that names an element to mask (BE-0171); the element is resolved to its frame at evaluation time.
A baseline is created or updated with the approve command
(cli) or the serve UI; a missing baseline fails the assertion. Pair it with
overrideStatusBar to keep the clock / battery deterministic. Diffs are surfaced in
report.html; for pixelmatch, only the surviving (non-discounted) pixels appear in the diff.
Element-scoped comparison (BE-0171). By default visual compares the whole screen, so any
unrelated change (a banner, a list that grew a row) fails the check and churns the baseline. Give
element: <Selector> to compare only that element: the screenshot is cropped to the element's
frame and the baseline is that crop, so the check ignores everything outside it. The selector is
resolved with the usual unique-resolution rules — an ambiguous selector fails immediately
rather than cropping the first match, and a selector matching nothing fails too. approve promotes
an element-scoped baseline exactly as it does a whole-screen one (the baseline is simply a smaller
image).
Selector-based masking (BE-0171). A pixel rectangle in exclude drifts the moment the layout
reflows or the device resolution changes. Naming the element instead — { selector: { label:
"last updated" } } — is stable across those changes: the element is resolved to its frame and
masked exactly as a rectangle is. A mask selector that matches nothing is a no-op (there is nothing
on screen to hide); an ambiguous one fails, consistent with the determinism rule. Selector masks
and rectangles can be mixed in one exclude list, and both work with an element-scoped comparison
(a mask inside the cropped element is translated into the crop's coordinates).
Network mocks (deterministic stubs)¶
mocks makes a test independent of a live server: when an outgoing request matches, BajutsuKit returns
a canned response instead of hitting the network. Each mock is { match, respond }.
matchreuses the request-side fields of the request matcher (method/url/urlMatches/path/pathMatches/bodyMatches).status/countdo not apply to a mock'smatch.respondis the canned reply:status(default200),headers(default{}),body(a string),delayMs(artificial latency). Omittingrespondreturns an empty200.
- name: GET answered by a mock stub
mocks:
- match: { method: GET, urlMatches: "example.com" }
respond:
status: 418 # real example.com returns 200; 418 proves the stub served it
headers: { Content-Type: text/plain }
body: "stubbed by bajutsu"
steps:
- tap: { id: net.fetch }
- wait: { until: { request: { method: GET, urlMatches: "example.com", status: 418 } }, timeout: 6 }
expect:
- request: { method: GET, urlMatches: "example.com", status: 418 }
Mocks are handed to BajutsuKit via the BAJUTSU_MOCKS env (dump_mocks, scenario/serialize.py). The formal
shape is in dsl-grammar.
Reuse, data, and tags¶
A small templating and macro layer wraps the core grammar. It runs at load time, before the deterministic run, so the runner only ever sees plain, fully-expanded scenarios. The normative rules (expansion order, ${ns.key} interpolation, depth limits) are in dsl-grammar. This section covers the authoring perspective.
Components (use → reusable steps)¶
A component is a separate file containing a list of params and a list of steps that reference them as ${params.<name>}. A use step invokes it, binding params via with. use is a compile-time macro: expand_components (scenario/expand.py) replaces it with the component's substituted steps before the run. Expansion is recursive — a component may itself use another, up to depth 25. It raises an error on a missing or unknown param, a residual ${params.*} referencing something undeclared, or a reference cycle. No use step survives into the run, so determinism is unaffected.
# login.component.yaml — a component file (a single mapping, loaded separately)
params: [user, pass]
steps:
- type: { text: "${params.user}", into: { id: auth.user } }
- type: { text: "${params.pass}", into: { id: auth.pass } }
- tap: { id: auth.submit }
# in a scenario — expands to the three steps above with params substituted
steps:
- use: { component: login.component.yaml, with: { user: alice, pass: hunter2 } }
- tap: { id: home.tab }
Data-driven scenarios (data / dataFile)¶
A scenario with data (inline rows) or dataFile (a CSV path — the two are mutually exclusive) is expanded into one scenario per row, substituting ${row.<column>} (expand_data, scenario/expand.py). Each derived scenario is renamed "<name> [row N: col=val, …]" and keeps the original preconditions, so every row reinstalls the app fresh and inherits the template's erase / reinstall.
- name: search returns a result
data:
- { q: dog, expect: "1 result" }
- { q: cat, expect: "2 results" }
steps:
- type: { text: "${row.q}", into: { id: search.field }, submit: true }
expect:
- label: { sel: { id: home.status }, equals: "${row.expect}" }
A string that is exactly one token (
"${row.qty}") takes the raw value (a number stays a number); a token embedded in a larger string is spliced in as text ("item-${row.id}").
A CSV dataFile has a header row naming the columns; each subsequent row becomes one scenario.
Refs stay inside the suite. A
usecomponent and adataFilepath resolve relative to the scenario file, and the resolved file must stay within the suite root (the scenarios dir the load started from). A ref that leaves it — an absolute path, a../chain that escapes the root, or a symlink pointing outside — is rejected with a clear error and never read, so a scenario cannot make the loader open a file outside its own tree (BE-0174). A relative ref that stays inside the root keeps working — a siblingcomponents/shared.yaml, or, from a scenario in a subdirectory, a../shared.yamlthat climbs no higher than the root.
Tags and selection¶
tags label a scenario; the CLI --tag / --exclude flags pick which scenarios run. A scenario is kept
when it carries at least one --tag (or none was given) and none of the --exclude tags —
--exclude wins over --tag (select_scenarios, scenario/select.py). Both flags accept a comma list.
- name: checkout smoke
tags: [smoke, checkout]
steps:
- tap: { id: cart.checkout }
uv run bajutsu run --target showcase-swiftui --tag smoke --exclude wip # run @smoke, skip anything @wip (across the app's scenarios dir)
Secrets (${secrets.X})¶
Declare secret environment-variable names in config (secrets: [API_TOKEN, ...]). Each declared name X is resolved from the environment and substituted into the executed step at action time as ${secrets.X}. The scenario file stores the token, never the value, and literal values are auto-masked in evidence, making secrets safe to commit and review. Unlike ${params.*} / ${row.*} (load-time expansion), this namespace is resolved by the run loop.
# config declares: secrets: [API_TOKEN]
steps:
- type: { text: "${secrets.API_TOKEN}", into: { id: auth.token } } # real value typed; token kept in the report
Runtime variables (${vars.*})¶
A step's extract modifier captures a UI element's property into vars.* after the step
executes. Subsequent steps (and scenario-level expect) can reference the captured value
via ${vars.<name>}.
steps:
- tap: { id: counter.inc }
extract:
count: { sel: { id: counter.value } } # vars.count ← element's value (default)
heading: { sel: { id: header }, prop: label } # vars.heading ← element's label
- assert:
- value: { sel: { id: other.field }, equals: "${vars.count}" }
Each extract entry specifies a sel (selector, resolved via resolve_unique) and an
optional prop (value | label | identifier; default value). If the selector
cannot be uniquely resolved or the property is None, the step fails.
Conditional steps (if)¶
A step can evaluate a condition (using the same assertion DSL) and branch:
steps:
- if:
condition: { exists: { id: dialog.alert } }
then:
- tap: { id: dialog.dismiss }
else:
- tap: { id: home.start }
The condition is evaluated against the current element tree (with ${...} interpolation).
If it passes, then steps run; otherwise else steps run (or nothing if else is omitted).
Nested steps share the same vars.* bindings as the enclosing scenario. capture and
extract modifiers are not allowed on if steps.
Iterating over elements (forEach)¶
A step can iterate over all elements matching a selector:
steps:
- forEach:
sel: { idMatches: "item.*" }
as: current
steps:
- tap: { id: "${vars.current}" }
The element list is snapshotted once at loop start. Each matched element's identifier is
stored as vars.<as> for the nested steps. An element with no identifier fails the step.
Zero matches is a no-op (success). The selector supports ${...} interpolation. capture
and extract modifiers are not allowed on forEach steps.
capture token grammar¶
Shared by capture: (per-step) and capturePolicy[].capture (rules). The form is
<kind>[.<modifier>].
- Kinds:
screenshot/elements/actionLog/deviceLog/network/video/appTrace - Modifiers:
before/after/around/onError
Validation is over the set of kinds and modifiers (scenario/models/_base.py _validate_capture). The
acquisition timing per kind, and which are captured, are in
evidence.
YAML caveat¶
PyYAML (YAML 1.1) resolves on/off/yes/no to booleans. To prevent the capturePolicy
trigger key on: from becoming True, Bajutsu's YAML loader (_yaml.py) treats only
true/false as booleans and keeps on/off/yes/no as strings.
from (provenance)¶
from: records which natural-language phrase a construct was recorded from (BE-0044). It is an
optional string attached at four levels — the scenario (the original goal), each step, each expect
assertion, and each capturePolicy rule — so a reviewer can see why each part exists and judge
whether record normalized the intent faithfully.
- name: open settings and reindex
from: "Open settings, reindex, and confirm the normalization setting is gone" # the original goal
steps:
- tap: { id: settings.open }
from: "Open settings"
expect:
- exists: { label: "Normalization setting changed", negate: true }
from: "The normalization setting is gone"
capturePolicy:
- on: { action: tap, idMatches: "*.submit" }
capture: [screenshot.after, network]
from: "Capture a screenshot and network log on every submit"
record(Tier 1, AI) is the only writer. It fillsfrom:while normalizing the goal into the structured scenario; a hand-authored scenario simply omits it (and a dumped scenario stays clean —from:is pruned when unset).run(Tier 2) ignores it entirely — provenance is authoring metadata, never read by the orchestrator, so it adds no AI to the gate and cannot affect pass/fail.- Grouping is emergent: when one utterance produces several steps, they carry the same
from:string; there is no span syntax.lintreports an advisory provenance-coverage figure (how many steps carryfrom:); it never fails a run. - Shown in
traceand the report.bajutsu traceprints each step's phrase inline (← "<phrase>") andreport.htmlshows it under the step, collapsing a run of the same phrase into one label — turning the timeline into a natural-language ↔ action map. - The phrase is kept verbatim in whatever language the author wrote (not translated).
Round-trip (load ⇄ dump)¶
load_scenarios(text) -> list[Scenario]: YAML string → validated models.dump_scenarios(scenarios) -> str: models → YAML (pruningNone/ empty list / empty dict for readability).
record's output goes through this dump_scenarios. The generated YAML reloads cleanly via
load_scenarios.