The authoring grammar — every accepted form
record resolves each spec step with deterministic rules first; anything
the rules cannot parse falls back to the LLM author (when a model backend
is configured). This page is the complete rules grammar. Nothing here
requires a model call, and everything here is covered by a test that
parses the exact examples shown (documented_grammar_examples_all_resolve
in crates/flowproof-agent/src/rules.rs — if the doc and the code drift,
CI fails).
Conventions: forms are case-insensitive in their keywords. <text> is
literal text (may carry ${VAR} secret references). A quoted "<label>"
is a text anchor — matched against visible text, accessible label
(aria-label), placeholder, or an associated <label> (both
<label>Name: <input/></label> wrapping and <label for>/id pairing).
Matching is exact first, then prefix ("Name" finds the field labelled
Name:), then ASCII case-insensitive as a last resort ("Close Account"
still finds the button reading Close account) — a case-sensitive match
always wins. page shows reads visible text plus the accessible names
of visible elements, so icon-only buttons that exist purely as an
aria-label count. Two escape hatches work inside any
quoted label: "css:<selector>" (web) and "id:<native id>" (DOM id,
UIA AutomationId, SAP scripting id). [2nd ] marks an optional 1-based
ordinal (2nd, 3rd, 10th) for when several elements match.
Actions (web, sap, vision — the generic grammar)
| Step | Notes |
|---|---|
Type <text> into the [2nd ]"<label>" field |
text anchor / css: / id: |
Type <text> into the <id> field |
bare native id |
Type <text> |
types into the FOCUSED element |
Replace the [2nd ]"<label>" field with <text> |
clear + type, one step |
Replace the <id> field with <text> |
|
Clear the [2nd ]"<label>" field / Clear the <id> field |
fill-with-empty semantics |
Select <option> from the [2nd ]"<label>" field |
native <select>: committed via the value setter, fires input+change (React-safe). in the and … dropdown also accepted |
Press the [2nd ]"<label>" button / Press the <id> button |
|
Right-click [the [2nd ]]"<text>" |
opens the element's context menu; Right click also accepted |
Upload <path> into the [2nd ]"<label>" field |
sets a file on a file-chooser input (may be hidden behind a styled button); relative paths resolve against the working directory at execution |
Upload <path> into the <id> field |
|
Click [the [2nd ]]"<text>" |
tabs, links, menu options, rows |
Press <Key> / Press <Mod>+<Key> |
Enter, Escape, Tab, Backspace, Delete, Space, arrows, Home/End, PageUp/PageDown; chords Control+V, Alt+Shift+Backspace. Mod (aliases CtrlOrMeta, ControlOrMeta) is the portable primary modifier: stored neutrally in the trace and resolved at execution — Meta on macOS, Ctrl elsewhere — so Press Mod+K recorded on a Mac replays on Linux CI |
Go to <path-or-URL> / Navigate to <path-or-URL> |
relative paths resolve against the flow URL's origin; on SAP this is transaction navigation (Go to /nVA01) |
Reload the page |
web |
Wait until page shows <text> [within <N>s] |
long-bound auto-waiting assert (default 60s) |
Assertions (every app — the shared grammar)
All assertion forms auto-wait (default 10s, recorded into the trace);
append within <N>s to any form to change the bound.
| Assert | Meaning |
|---|---|
page shows <text> |
the whole surface (page text / window subtree / SAP session / OCR frame) contains <text> — the page shows <text> also accepted |
page shows <text> <N> times |
exact occurrence count of the TEXT |
page does not show <text> |
waits for it to be GONE |
the [2nd ]"<label>" field contains <text> |
input VALUE, by label |
the <id> field contains <text> |
input VALUE, by native id |
the [2nd ]"<target>" shows <text> |
element-scoped substring |
the [2nd ]"<target>" is visible / is not visible |
target resolves / does not resolve |
the [2nd ]"<target>" is enabled / is disabled |
platform enabled state (disabled/aria-disabled on web, UIA IsEnabled on desktop) |
Out-of-band assertions (any app; structured steps, not prose)
- assert_sql:
connection: reporting # resolved from FLOWPROOF_SQL_REPORTING
query: SELECT count(*) FROM orders WHERE ref = '4711'
equals: "1"
- assert_api:
request: GET ${API}/orders/4711
status: 200
body_contains: "confirmed"
- assert_api: # authenticated JSON POST
request: POST ${API}/connections/test
headers:
Authorization: Bearer ${SESSION_TOKEN}
body:
provider: postgres
connectionString: ${TEST_CONN_STRING}
status: 200
body_contains: "Database not yet supported!"
headers values and body string values may carry ${VAR} refs — the
trace stores only the raw reference; tokens and connection strings resolve
when the probe fires (record and every replay). body is any YAML
(mapping, list, or string), sent as JSON with content-type: application/json unless you set your own content-type header — yours
wins. A body on GET/HEAD/DELETE is rejected at parse time.
Visual assertions (structured step)
- assert_screenshot:
name: dashboard # baseline PNG name (no path, no extension)
mask: ["css:.clock", "Sync"] # optional: selectors blanked before compare
threshold: 0.001 # optional: fraction of pixels allowed to differ (default 0)
record captures the surface, blanks each mask's element rect, and mints
<spec-stem>.baselines/<name>.png next to the trace — re-recording (or
record --reuse) is how baselines refresh. Replay captures with the
same masks and compares pixel-exact (up to threshold); on failure
the run bundle gains visual/<name>.actual.png and visual/<name>.diff.png
(differing pixels in red) and the message names the diff percentage.
Masks take the same forms as quoted labels (text anchor, css:, id:)
and every mask must resolve — a silently-unmasked volatile region would
mint a flaky baseline. Pin the viewport with browser: so capture
dimensions stay stable across machines.
Network mocks (web flows; spec-level, not steps)
mock:
- url_contains: /api/rates # substring match on the request URL
method: GET # optional; any method when absent
status: 200 # optional; default 200
body: # any YAML: string served verbatim
rate: 1.23 # (text/plain), anything else as
source: mocked # JSON; content_type: overrides
Requests matching a rule are answered inside the browser — the real host
is never contacted (it need not even exist). The rules travel in the
trace header and apply identically at record and replay: what was
mocked once is mocked always, which is what keeps the two executions
equivalent. Mocked responses carry permissive CORS headers and answer
preflights, so cross-origin fetch() calls just work. The tool for
third-party calls (payments, analytics) and hard-to-provoke server
states; for asserting on real APIs, use assert_api instead.
Browser config (web flows; spec-level, not steps)
browser:
viewport: # device emulation, applied before navigation
width: 390
height: 844
device_scale_factor: 3 # optional; default 1
mobile: true # optional; mobile layout + meta-viewport
touch: true # optional; emulate a touch screen
user_agent: my-agent # optional; navigator.userAgent override
args: ["--lang=en-US"] # optional; extra Chrome flags
The config travels in the trace header and applies identically at
record and replay — a flow recorded on an emulated phone never replays
on a desktop viewport. This is how *.mobile test variants and
deterministic-seeding user agents (previously an env-var wrapper around
Chrome) become first-class. args forces a private (non-shared) browser
for the flow, since flags only apply at process start — expect its cold
start. A suite's suite.yaml may carry the same browser: block as a
default for every flow; a flow's own block wins outright.
App sugar
- calc:
Type <digits>(one press per digit),Press plus|minus|times|divided by|equals,assert: display shows <number>. - notepad:
Type <text>,assert: document contains <text>(plus the shared grammar).
When a step doesn't parse
The error names the accepted forms for that app. Anything freeform (e.g.
Smash the shiny button) is handled by the LLM author when a model
backend is configured (FLOWPROOF_AI_PROVIDER / FLOWPROOF_AI_API_KEY)
— the model grounds the step against the live scene and can never invent
a selector; replay stays zero-model either way. See
getting-started.
When a step is too ambiguous to author at all ("make required field
changes" — which fields?), recording fails with a structured
clarification payload: the stuck step plus the live screen's field
inventory, via record --json, the MCP record tool, or Python's
ClarificationNeeded. The driving agent rewrites the step into concrete
grammar and re-records — see self-help.md for the loop.