Out-of-band assertions
Structured assertions outside the UI, including assert_spreadsheet and how reads vs. writes are retried.
- 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!"
- assert_api: # response-side JSON-field assertion
request: GET ${API}/testData/users
status: 200
body_json: results.0.balance # a dotted path into the JSON response
equals: 150953 # the leaf at that path must equal this
- assert_api: # how many elements are in a collection
request: GET ${API}/testData/users
status: 200
body_json: results # the path must resolve to an ARRAY
count: 5 # exactly 5 elements (count_at_least: 2 = a minimum)
- assert_api: # response-header assertion
request: GET ${API}/testData/users
status: 200
header: Content-Type # response header name (case-insensitive)
header_contains: json # a substring of the header valueheaders 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.
body_json reads a value out of the JSON response and asserts on it,
alongside status and body_contains (all three may appear on one step;
they are checked in the order status, then body_contains, then body_json).
The path is a dotted sequence of segments, each a plain object key or a
decimal array index: results.0.balance means "the balance field of the
first element of the results array". That is the whole path language:
there are no wildcards, filters, brackets, or quoting, so a key that
literally contains a dot cannot be reached. One body_json per step; to
assert several fields, use several steps.
body_json on its own is an existence check: the path must resolve to a
scalar leaf (mirroring assert_sql, where omitting equals means a row
merely has to exist). Add equals (a string, number, or boolean) to also
check the value; equals without body_json is a parse-time error. A
string equals may carry a ${VAR} ref, resolved at probe time exactly
like body_contains (only the ref travels in the trace). Comparison has
two tiers: when both the leaf and equals are numbers, they compare
numerically (150953 equals 150953); otherwise they compare by exact
canonical text, so a string leaf never numeric-matches a number (the
leaf "0953" does not equal the number 953). Only leaves compare, so
there is no deep object equality.
The extracted response value never enters the trace: only the request and
the raw expectation are stored, and the plucked value exists solely inside
the live comparison, re-fetched on both record and replay. The failure
modes are soft (the auto-wait loop keeps polling until they clear or the
timeout elapses): a non-JSON body reports "response body is not valid
JSON"; a path that runs off the document names the segment where it died
(path 'results.0.balance' stops at segment 'balance'); a path that lands
on an object or array reports "path resolves to a non-scalar; assert a leaf
value".
header asserts on a response header, alongside status, body_contains,
and body_json (all may appear on one step; they are checked in the order
status, then body_contains, then body_json, then header). The header NAME is
case-insensitive, per HTTP: header: Content-Type matches a response that
spells it content-type. If the response repeats the header, its values are
joined with ", " (HTTP field-value semantics) before matching. One header per
step; to assert several headers, use several steps.
header on its own is an existence check: the header must be present
(mirroring body_json alone, where reaching a scalar leaf is the whole
assertion). Add header_equals (exact value) or header_contains (a
substring) to also check the value; at most one of the two per step, and
either without header is a parse-time error. Value comparison is
case-SENSITIVE (unlike the name). A header_equals/header_contains value
may carry a ${VAR} ref, resolved at probe time exactly like body_contains
(only the ref travels in the trace). The live header value never enters the
trace: it exists solely inside the comparison, re-fetched on both record and
replay. The failure modes are soft: an absent header reports "response has no
'' header (status )", and a value mismatch reports "header
'' is '', expected <equals|contains> '' (status )".
count (exactly N) and count_at_least (a minimum) ask how many elements
are in the array at body_json. Either requires body_json, at most one of
the two may appear, and neither pairs with equals (a count needs an array,
equals needs a scalar leaf) - all three are parse-time errors. When the
path resolves to something other than an array, the failure names what was
actually there: "path 'page' is an object, count requires an array (status
200)". A wrong count reports both sides: "path 'results' has 3 elements,
expected exactly 9 (status 200)". Both are soft failures, so on a GET they
auto-wait: "poll until the collection has N rows" is a real pattern.
assert_spreadsheet: an exported file, read directly
- assert_spreadsheet:
path: ${captured.pir_export} # may carry ${captured.x} / ${VAR} refs
sheet: Sheet1 # optional; the workbook's first sheet if absent
at: B2 # an absolute A1 reference ...
- assert_spreadsheet:
path: ${captured.pir_export}
column: Net Price # ... OR a header + row anchor, not both
row_contains: "100-100"
equals: "12.50"Reads the file directly (calamine) rather than through UI Automation over
Excel's own grid — the manual test's own second check ("open the file and
review it") is a screen a person can look at, but its GRID support over UIA
is untested and known-flaky, so the out-of-band read is the one that must
hold. path resolves ${captured.x} then ${VAR}, exactly like a typed
field — the common case is a path a Wait until the download completes as <name> step captured moments earlier in another surface.
The cell is addressed EITHER by at (an absolute A1 reference) OR by
column+row_contains together — never both, and never neither, both
parse-time errors. column resolves against the sheet's first row the same
two-rung ladder a web table cell uses: exact match after trim, then a
unique substring match; an ambiguous or missing header is a parse-time-shaped
failure naming what was asked for. row_contains is the unique data row
(excluding the header) where ANY cell's text contains it — ambiguous or
absent is reported the same way.
equals/contains compare the cell's text (its canonical rendering — a
number reads as "12.5", not "12.50000"); at most one may be set, a
parse-time error otherwise. With neither, resolving the cell is the whole
assertion — mirroring assert_sql, where omitting equals means a row
merely has to exist. Like assert_sql, this is always a READ: a
just-landed download may still be mid-write when the first poll fires, so
the auto-wait loop keeps re-opening the file until it resolves or the bound
(timeout_seconds, default 10s) elapses.
Retries: reads are polled, writes are sent once
A failing assertion auto-waits by RE-SENDING its probe until the bound
expires. That is right for a read (the API is still converging) and wrong
for a write, because the probe IS the mutation: polling a failing POST
delivers it once per tick, and a single failing step was measured
delivering 41 POSTs inside the default 10s bound. So only GET and
HEAD are retried. POST, PUT, PATCH and DELETE are sent exactly
once, and their failure says so. (DELETE is idempotent per HTTP but not
side-effect-free, so it is grouped with the writes.) assert_sql is a
read and keeps polling.
Override per step when the default is wrong:
- assert_api: # poll a write until it converges
request: POST ${API}/jobs
status: 202
retry: true
- assert_api: # ask a read exactly once
request: GET ${API}/jobs/1
status: 200
retry: falseOn releases without retry:, timeout_seconds: 0 is the mitigation: it
leaves no wait budget, so the probe fires once.