Skip to content

Assert response shape and timing

An # expect: section turns a block from “show me what came back” into “fail the runbook if any of these aren’t true”. Useful for smoke tests committed to git and run as a CI step, or for the local “did staging drift since yesterday?” check.

Inside any block (HTTP or DB), add a section starting with # expect: on its own line. Each line below is one assertion: <field> <operator> <value>.

```http
GET {{BASE_URL}}/health
# expect:
# status == 200
# time < 500ms
# body.status == "ok"
# body.version matches /^v\\d+\\.\\d+\\.\\d+$/
```

Anything after # expect: until the closing fence (or a blank line followed by non-# content) is parsed as assertions.

Same fields as block references, plus two HTTP-only:

FieldNotes
statusnumeric HTTP status
timetotal elapsed (in ms / s)
sizeresponse body bytes
body.<path>JSON path
headers.<name>response header (case-insensitive)
cookies.<name>set-cookie value
affectedDB only — INSERT/UPDATE/DELETE row count
row[N].<col>DB only — column of nth result row
rows.lengthDB only — number of rows returned
OperatorMeaningWorks on
==strict equalitystrings, numbers, booleans, null
!=not equalsame
< <= > >=numeric comparisonnumbers (incl. time, size)
containssubstring or array membershipstrings, arrays
not containsinverse of containsstrings, arrays
matchesregex matchstrings (regex between /.../)
existspath resolves (no RHS needed)any field
not existsinverseany field
istype check (number, string, array, object, null)any field

time and size accept suffixes:

time < 500ms
time < 1.5s
time < 1m
size < 10kb
size < 100mb
SuffixMeaning
msmilliseconds
sseconds
mminutes (rare)
b / kb / mb / gbbytes (powers of 1024)

No suffix means raw integer (time < 500 is < 500ms by default, since time is in ms; size < 100 is bytes).

References work inside # expect: like anywhere else:

```http alias=cutoff
GET {{BASE_URL}}/last-sync
```
```http
GET {{BASE_URL}}/changes
# expect:
# status == 200
# body.since == {{cutoff.body.timestamp}}
# body.changes.length > 0
```

Useful for “did the API agree with what we last fetched?” checks.

When any assertion fails:

  1. The block toolbar turns red.
  2. The result panel shows the expected vs actual side by side:
    ✗ status == 200
    expected: 200
    actual: 503
    ✓ time < 500ms (143ms)
    ✗ body.status == "ok"
    expected: "ok"
    actual: "degraded"
  3. Downstream blocks don’t run in a Run-all — httui short-circuits on the first failure.
  4. The status bar pass/fail count updates: 2 passed · 1 failed.

Press Cmd+Shift+Enter (or click Run all in the status bar) to run every block top-to-bottom. The summary shows total pass/fail across all # expect: sections.

For CI, the httui-tui binary takes the same vault and can run specific runbooks:

Terminal window
httui-tui run runbooks/smoke-staging.md --env staging
# exits 0 if all expects pass, non-zero if any failed

Pipe into your CI step like any other test command.

Comment it out with an extra #:

# expect:
# status == 200
## time < 500ms ← skipped (flaky on cold cache)
# body.status == "ok"

The double-## is httui’s “ignore this expect line” convention. Better than deleting because the intent is preserved.

You can have more than one — they’re aggregated:

GET {{BASE_URL}}/users
# expect:
# status == 200
GET {{BASE_URL}}/users/admin
# expect:
# status == 200
# body.role == "admin"

Each # expect: applies to the most recent block. Useful when the block has logical sub-steps you want to assert separately.

SymptomCauseFix
body.X exists check fails on nullexists returns false for null body fieldsUse body.X is null to distinguish
Regex doesn’t matchForgot to escape / inside the regexmatches /^v\\d+\\/release$/ (escape / as \\/)
Time assertion always fails on first runCold connection / DNS lookupFirst run baseline is slower; consider time < <p99-of-typical-runs>
Header assertion case-mismatchHTTP headers are case-insensitive — httui normalizesheaders.content-type and headers.Content-Type are the same