Skip to content

Flow commands

Every step in a flow is one command: - name (bare), - name: value (scalar) or - name: with a map of keys. Selector-taking commands accept a bare string (exact visible text) or a selector object — see Flow file & selectors. Durations are milliseconds. ${VAR} is substituted in string values — see Variables.

Command Summary web android ios vscode
App & navigation
launchApp Launch the app (or open the URL) ✓ ✓ ✓ ✓
stopApp Stop the app ✓ ✓ ✓ ✓
installApp Install a build (apk / ipa) — ✓ ✓ —
openLink Open a URL / deep link ✓ ✓ ✓ —
clearState Wipe the app’s stored data ✓ ✓ ✓ ✓
grantPermissions Grant the app’s runtime permissions — ✓ ✓ —
setPermissions Allow / deny individual permissions — ✓ ✓ —
back Go back ✓ ✓ — —
vscodeCommand Run a VS Code command — — — ✓
Taps & gestures
tapOn Tap an element or a point ✓ ✓ ✓ ✓
longPressOn Long-press an element or a point ✓ ✓ ✓ ✓
doubleTapOn Double-tap an element or a point ✓ ✓ ✓ ✓
scroll Scroll the screen ✓ ✓ ✓ ✓
swipe Swipe by direction or between points ✓ ✓ ✓ ✓
scrollUntilVisible Scroll until an element appears ✓ ✓ ✓ ✓
dragAndDrop Drag one element onto another ✓ ✓ ✓ ✓
check Tick a checkbox or switch ✓ ✓ ✓ ✓
uncheck Untick a checkbox or switch ✓ ✓ ✓ ✓
hover Move the pointer over an element ✓ — — ✓
Text input & keys
inputText Type into the focused field ✓ ✓ ✓ ✓
inputRandomEmail Type a random e-mail ✓ ✓ ✓ ✓
inputRandomPersonName Type a random person name ✓ ✓ ✓ ✓
inputRandomNumber Type a random number ✓ ✓ ✓ ✓
inputRandomText Type random text ✓ ✓ ✓ ✓
inputRandomCityName Type a random city name ✓ ✓ ✓ ✓
inputRandomCountryName Type a random country name ✓ ✓ ✓ ✓
inputRandomColorName Type a random color name ✓ ✓ ✓ ✓
clearText Clear the focused field ✓ ✓ ✓ ✓
eraseText Erase the last N characters ✓ ✓ ✓ ✓
pressKey Press a key or a combo ✓ ✓ ✓ ✓
hideKeyboard Dismiss the on-screen keyboard ✓ ✓ ✓ —
setClipboard Set the flow’s clipboard register ✓ ✓ ✓ ✓
pasteText Paste the register into the field ✓ ✓ ✓ ✓
Forms & page (web)
selectOption Choose an option in a ✓ — — ✓
uploadFile Attach files to a file input ✓ — — ✓
assertUrl Assert the page address ✓ — — ✓
Assertions & waits
assertVisible Assert an element is visible ✓ ✓ ✓ ✓
assertNotVisible Assert an element is gone ✓ ✓ ✓ ✓
waitForVisible Wait until an element is visible ✓ ✓ ✓ ✓
extendedWaitUntil Wait until visible / not visible ✓ ✓ ✓ ✓
waitForAnimationToEnd Wait for the screen to settle ✓ ✓ ✓ ✓
assertTrue Assert over values or a JS expression ✓ ✓ ✓ ✓
copyText Copy an element’s text into a variable ✓ ✓ ✓ ✓
assertFile Assert a workspace file exists — — — ✓
assertScreenshot Visual check against a baseline ✓ ✓ ✓ ✓
Screenshots & video
takeScreenshot Save a screenshot ✓ ✓ ✓ ✓
startRecording Start screen recording ✓ ✓ ✓ ✓
stopRecording Stop recording and save the video ✓ ✓ ✓ ✓
Device state
setLocation Mock the GPS location ✓ ✓ ✓ —
travel Move through GPS points ✓ ✓ ✓ —
setOrientation Rotate the device ✓ ✓ ✓ —
setAirplaneMode Turn airplane mode on / off — ✓ — —
toggleAirplaneMode Toggle airplane mode — ✓ — —
addMedia Add photos / videos to the gallery — ✓ ✓ —
clearKeychain Clear the iOS keychain — — ✓ —
Control flow
runFlow Run a helper file or an inline block ✓ ✓ ✓ ✓
repeat Repeat a block ✓ ✓ ✓ ✓
retry Re-run a block on failure ✓ ✓ ✓ ✓
AI
assertWithAI AI screen assertion ✓ ✓ ✓ ✓
assertNoDefectsWithAI AI visual-defect check ✓ ✓ ✓ ✓
extractTextWithAI AI text extraction into a variable ✓ ✓ ✓ ✓
Scripting
evalScript Run inline JavaScript ✓ ✓ ✓ ✓
runScript Run a JavaScript file ✓ ✓ ✓ ✓
API & data
request HTTP request with extract / assert ✓ ✓ ✓ ✓
sql SQL statement with extract / assert ✓ ✓ ✓ ✓

The coverage columns are the engine’s own: tapstep schema publishes them per command and this table prints them. Desktop-app targets (--driver app) are not a column of their own — they share the web DOM walk but lack most optional capabilities, see Testing desktop apps.

Launches the app under test — on web it navigates to the URL. The bare string form takes the app id / URL; the map form adds clearState (wipe stored data first), stopApp (force a cold start), permissions (a name: allow|deny|unset map, mobile) and arguments (launch arguments, mobile).

Keys: appId — string · required; clearState — bool; stopApp — bool; permissions — map; arguments — map.

commands:
- launchApp: com.example.app
- launchApp:
appId: com.example.app
clearState: true
permissions:
notifications: allow

Terminates the app: bare - stopApp uses the flow’s appId, - stopApp: <appId> stops another one. killApp is an alias.

Aliases: killApp

Keys: appId — string.

commands:
- stopApp
- stopApp: com.example.other

Installs a build from a path. grant: true grants runtime permissions right after the install.

Platforms: android, ios

Keys: path — string · required; grant — bool.

commands:
- installApp: build/app-debug.apk
- installApp:
path: build/app.ipa
grant: true

Opens a URL or deep link: browser navigation on web, am start -a VIEW on Android, simctl openurl on iOS. In a browser a relative link — /login, ./next, ?page=2 — is read against the page already open, or against a URL appId; with neither the step fails at once instead of waiting out the load.

Platforms: web, android, ios

Keys: url — string · required.

commands:
- openLink: https://example.com/reset?token=${TOKEN}

Resets stored state: localStorage + cookies on web, pm clear on Android, the simulator’s app data container on iOS (simulator only). Bare form targets the flow’s appId; pass an app id to target another app.

Keys: appId — string.

commands:
- clearState
- clearState: com.example.other

Grants the common runtime permissions (simctl privacy on iOS, pm grant on Android). Bare form uses the flow’s appId. Not supported on web — browsers have no permission model the driver can grant.

Platforms: android, ios

Keys: appId — string.

commands:
- grantPermissions

Sets permissions one by one: a name: allow|deny|unset map (all covers every permission), optionally under permissions: next to an appId. Names: camera, location, microphone, notifications, photos, contacts, calendar, bluetooth, motion, medialibrary, phone, reminders, siri, sms, storage.

Platforms: android, ios

Keys: appId — string; permissions — map.

commands:
- setPermissions:
camera: allow
location: deny
- setPermissions:
appId: com.example.other
permissions:
all: unset

Browser history back on web, the hardware Back key on Android. On iOS there is no system back — the step is a no-op there.

Platforms: web, android

commands:
- back

VS Code flows only. id — a bare workbench.action.closeAllEditors, or { id, args } — goes through the helper extension the driver bundles, which answers when the command resolves; { title: … } types the palette title instead and is done when the palette closes. A bare string is read as an id when it is shaped like one (dotted, no spaces) and as a palette title otherwise. args needs id: the palette takes no arguments, and asking for both is an error rather than a silent drop — as is id together with title, or any other key in the map. Works in before: / after: too; before: clean is six of these steps.

Platforms: vscode

Keys: id — string; title — string; args — list of strings.

Exclusive: one of id, title.

commands:
- vscodeCommand: workbench.action.closeAllEditors
- vscodeCommand:
title: "My Ext: Generate Config"
- vscodeCommand:
id: workbench.action.openSettings
args: ["editor.fontSize"]

Taps the first match of a selector; off-screen targets are scrolled into view first. { point: "x, y" } taps coordinates instead (pixels or %). repeat taps N times with delay ms between taps (default 100). timeout (ms, default 5000) overrides the element wait for this tap alone.

Keys: selector — selector; repeat — integer; delay — ms; timeout — ms; point — point "x, y".

commands:
- tapOn: "Log in"
- tapOn:
id: submit
- tapOn:
point: "50%, 90%"
- tapOn:
text: "+"
repeat: 3
delay: 200
- tapOn:
text: "Continue"
timeout: 30000

Presses and holds for 600 ms — a selector or { point: "x, y" }.

Keys: selector — selector; point — point "x, y".

commands:
- longPressOn: "Message"
- longPressOn:
point: "200, 400"

Two quick taps on a selector or { point: "x, y" }.

Keys: selector — selector; point — point "x, y".

commands:
- doubleTapOn: "Photo"

Scrolls one screen up or down (bare - scroll = down). Horizontal moves are swipe: left|right. within is an anchor, not the thing that moves: name something you can see inside the container — a heading, a row, the column around a list — and whatever scrolls under it is scrolled instead of the window.

Keys: direction — enum (up | down); within — selector.

commands:
- scroll
- scroll: up

swipe: up|down|left|right, or { start, end } as "x, y" points in pixels or % of the screen.

Keys: direction — enum (up | down | left | right); start — point "x, y"; end — point "x, y".

Exclusive: direction not with start, end.

commands:
- swipe: left
- swipe:
start: "50%, 80%"
end: "50%, 20%"

Scrolls repeatedly until the selector is visible. Map form: direction (up|down|left|right, default down), timeout ms, speed 0–100, centerElement: true to finish with the element centred. Gives up after --scroll-attempts swipes (default 5). within is the same anchor as on scroll. When the element is already in the page and what hides it is a box with a scroller of its own (or the little window a webview draws in), that box is asked to show it rather than the screen being swiped; the page itself is always scrolled a screen at a time, and an overflow: hidden box is never moved.

Keys: selector — selector · required; direction — enum (up | down | left | right); timeout — ms; speed — integer; centerElement — bool; within — selector.

commands:
- scrollUntilVisible: "Terms of service"
- scrollUntilVisible:
id: footer
direction: down
centerElement: true

Locates both elements and drags between their centres. duration (ms, default 600) is slow enough for a drop target’s hover state to fire, which is what most drag-and-drop implementations wait for. In a browser one step covers both kinds of page: a list that follows the pointer gets the moves, and a board built on HTML5 drag-and-drop gets its dragstart … drop.

Keys: from — selector · required; to — selector · required; duration — ms; path — pointList.

commands:
- dragAndDrop:
from: "Card"
to: { id: done-column }

Reads the element’s checked state first and taps only when it isn’t already on, then confirms the tap took — a disabled or covered control fails the step instead of passing silently. Safe to re-run.

Keys: selector — selector · required.

commands:
- check: "Remember me"

The mirror of check: taps only when the element is on, then confirms it went off.

Keys: selector — selector · required.

commands:
- uncheck: { id: newsletter }

Moves the pointer without pressing — for menus, tooltips and controls that only appear under the cursor; a touchscreen has no hover.

Platforms: web, vscode

Keys: selector — selector · required.

commands:
- hover: "Account"
- assertVisible: "Sign out"

Types text into the currently focused input. ${VAR} is substituted first.

Keys: text — string · required.

commands:
- tapOn: "Email"
- inputText: "${EMAIL}"

Types a generated e-mail address into the focused field.

Keys: length — integer.

commands:
- inputRandomEmail

Types a generated first + last name.

Keys: length — integer.

commands:
- inputRandomPersonName

Types a random number; length digits (default 8) — bare, : N or { length: N }.

Keys: length — integer.

commands:
- inputRandomNumber: 6

Types random letters; length characters (default 8) — bare, : N or { length: N }.

Keys: length — integer.

commands:
- inputRandomText:
length: 12

Types a city name from a built-in list.

Keys: length — integer.

commands:
- inputRandomCityName

Types a country name from a built-in list.

Keys: length — integer.

commands:
- inputRandomCountryName

Types a color name from a built-in list.

Keys: length — integer.

commands:
- inputRandomColorName

Empties the focused text field.

commands:
- tapOn: "Search"
- clearText

- eraseText: N deletes N characters before the caret; bare - eraseText clears the whole field.

Keys: count — integer.

commands:
- eraseText: 3

The key names are listed under the step’s own key below, each with the screens that send it — a phone sends all of them, the iOS simulator takes the four a text field understands. Names are case-insensitive. Combos like cmd+shift+p (mods ctrl/control, alt/option, shift, cmd/meta/win/super) are a keyboard’s, so they work on web and VS Code targets.

Keys: key — enum (enter | home (android) | back (web, android) | backspace | tab | escape | arrow up (web, android, vscode) | arrow down (web, android, vscode) | arrow left (web, android, vscode) | arrow right (web, android, vscode) | volume up (android) | volume down (android) | lock (android) | power (android)) · required.

commands:
- pressKey: enter
- pressKey: cmd+shift+p

Dismisses the software keyboard (blur on web, best-effort on iOS).

Platforms: web, android, ios

commands:
- hideKeyboard

Stores text in the flow’s copy register — the same one copyText fills and ${copiedText} reads. It is a flow-level register, not the OS clipboard.

Keys: text — string · required.

commands:
- setClipboard: "hello"
- pasteText

Types the copy register (copyText / setClipboard, else a copiedText variable) into the focused field.

commands:
- copyText: "Order #"
- tapOn: "Search"
- pasteText

Picks an option by its visible text (option), by the underlying value (optionValue) or by position (optionIndex) — exactly one of the three. Fires input and change, so frameworks see the edit.

Platforms: web, vscode

Keys: selector — selector · required; option — string; optionValue — string; optionIndex — integer.

Exclusive: one of option, optionValue, optionIndex.

commands:
- selectOption:
id: country
option: "Germany"

A file dialog is browser chrome and cannot be clicked, so the files are handed to the input directly. files takes a path or a list of paths; a relative one is looked for next to the flow file, then in the working directory, and a path that names no file fails the step.

Platforms: web, vscode

Keys: selector — selector · required; files — list of strings · required.

commands:
- uploadFile:
id: avatar
files: fixtures/face.png

The bare form matches exactly; the map form takes one of equals, contains or regex, plus timeout (ms). Polls until it matches, so a click that navigates has time to land.

Platforms: web, vscode

Keys: equals — string; contains — string; regex — string; timeout — ms.

Exclusive: one of equals, contains, regex.

commands:
- assertUrl: { contains: "/checkout" }

The engine settles before every command, so most flows need no explicit wait at all. On web a page counts as settled when four signals are quiet at once — the document has finished loading, no request is in flight (streams that stay open by design, and anything older than 5 s, do not count), the DOM has not changed for 150 ms, and the address has not changed for 150 ms — and two reads of the screen come back the same, ignoring drift of up to 2 px so an animation’s tail is not mistaken for movement. On mobile and native the platform’s own idle signal stands in for the first three. Settling is capped by --settle-timeout (2000 ms); a step’s own timeout: raises that cap for that step. A screen whose tree is slower to read than that budget still gets read twice — the cap stretches to what the reads themselves cost, so a slow page reads slow, not unsettled.

A run that could not settle in time still goes on, and says so. assertNotVisible, waitForNotVisible and when: notVisible only pass on a settled screen: an element missing from a page that is still drawing itself has not gone away, it has not arrived — so they fail with the reason instead of passing on a half-drawn screen. Everything else (tapOn, assertVisible, inputText, …) acts anyway at the deadline and the step carries acted on a page that had not settled (Network(2)), plus settled: false and unsettledReason in the report. The reasons are Loading, Network(n), Mutating, Navigating and Timeout.

Waits up to timeout ms (default 5000, --wait-timeout) for the selector to be visible, then fails the step if it isn’t.

Keys: selector — selector · required; timeout — ms.

commands:
- assertVisible: "Welcome"
- assertVisible:
id: toast
timeout: 10000

Polls until the selector is absent on a settled screen (up to timeout ms, default 5000); fails if it is still visible, and fails with the reason if the page never settled — an element missing from a page still drawing itself is not proof it went away.

Keys: selector — selector · required; timeout — ms.

commands:
- assertNotVisible: "Loading…"

Same as assertVisible; reads better when a step is a wait, not a check.

Keys: selector — selector · required; timeout — ms.

commands:
- waitForVisible:
text: "Dashboard"
timeout: 15000

Alias form: { visible: <selector> } or { notVisible: <selector> } plus timeout ms.

Keys: visible — selector; notVisible — selector; timeout — ms.

commands:
- extendedWaitUntil:
notVisible: "Spinner"
timeout: 20000

Waits until the screen stops changing; timeout ms caps the wait (default --settle-timeout, 2000).

Keys: timeout — ms.

commands:
- waitForAnimationToEnd
- waitForAnimationToEnd:
timeout: 5000

{ equals: [a, b] } / { contains: [haystack, needle] } over ${}-substituted strings, or a bare string evaluated as JavaScript in the flow’s script context (assertTrue: "${count} > 1").

Keys: expression — string; equals — list of strings; contains — list of strings.

Exclusive: one of expression, equals, contains.

commands:
- copyText:
id: total
into: total
- assertTrue:
equals: ["${total}", "42"]
- assertTrue: "Number(total) > 40"

Reads the element’s text into into (default copiedText), available as ${name} and as a JS global. Alias copyTextFrom.

Aliases: copyTextFrom

Keys: selector — selector · required; into — string.

commands:
- copyText:
id: order-number
into: order

For VS Code extension tests: asserts that path (relative to the open workspace) exists and, with contains, includes the text.

Platforms: vscode

Keys: path — string · required; contains — string.

commands:
- assertFile:
path: out/report.md
contains: "PASS"

Compares the screen with <name>.png; the first run creates the baseline. tolerance is the allowed percentage of differing pixels (default 0.5); a failing actual is saved as <name>.actual.png.

Keys: name — string · required; tolerance — number.

commands:
- assertScreenshot: checkout
- assertScreenshot:
name: screens/home
tolerance: 1.5

Writes <name>.png (default screenshot.png) — relative to the current directory in the CLI, next to the run’s artifacts in the desktop.

Keys: name — string.

commands:
- takeScreenshot: after-login

Starts recording (native on Android/iOS, screencast on web); stopRecording writes <name>.mp4 (default recording.mp4). Whole-run video is simpler with tapstep test --video.

Keys: name — string.

commands:
- startRecording: checkout
- tapOn: "Pay"
- stopRecording

Ends the recording started by startRecording and saves the file.

commands:
- stopRecording

Overrides the device location with { lat, lon } (geolocation override on web).

Platforms: web, android, ios

Keys: lat — number · required; lon — number · required.

commands:
- setLocation:
lat: 43.238
lon: 76.889

Sets the location to each "lat, lon" point in turn; with speed (m/s) the pause between points follows the distance, otherwise 1 s.

Platforms: web, android, ios

Keys: points — list of strings · required; speed — number.

commands:
- travel:
points:
- "43.238, 76.889"
- "43.240, 76.900"
speed: 15

portrait, landscapeLeft (landscape), landscapeRight, upsideDown (case-insensitive).

Platforms: web, android, ios

Keys: orientation — enum (portrait | landscapeLeft | landscapeRight | upsideDown) · required.

commands:
- setOrientation: landscape

enabled or disabled (true/false also work). Android only.

Platforms: android

Keys: mode — enum (enabled | disabled) · required.

commands:
- setAirplaneMode: enabled

Flips airplane mode. Android only.

Platforms: android

commands:
- toggleAirplaneMode

Pushes one path or a list of paths into the device’s media library.

Platforms: android, ios

Keys: paths — list of strings · required.

commands:
- addMedia:
- fixtures/photo.jpg
- fixtures/clip.mp4

Wipes the simulator’s keychain. iOS only.

Platforms: ios

commands:
- clearKeychain

runFlow: path.helper.yaml inlines a helper (must end in .helper.yaml; the path is looked up in the calling file’s folder, then from the project root — write it from the root, helpers/login.helper.yaml, and the line works in a flow at any depth). The map form takes file or inline commands, an optional when ({ visible | notVisible: <selector> } or { platform: android | [web, vscode] } — the names are web, android, ios, vscode) and env — variables scoped to the block (shadowed names are restored afterwards; new ones persist). Helper params: are passed via env.

Keys: file — string; when — condition; env — map; commands — commands.

commands:
- runFlow: helpers/login.helper.yaml
- runFlow:
file: helpers/login.helper.yaml
env:
USER: qa@example.com
- runFlow:
when:
visible: "Accept cookies"
commands:
- tapOn: "Accept cookies"

times: N or while: <condition> ({ visible | notVisible: <selector> }), plus commands. A while loop stops after 1000 iterations.

Keys: times — integer; while — condition; commands — commands · required.

commands:
- repeat:
times: 3
commands:
- tapOn: "+"
- repeat:
while:
visible: "Load more"
commands:
- tapOn: "Load more"

Runs commands; on failure retries up to maxRetries times (default 1). A pass after retries is reported with a note.

Keys: maxRetries — integer; commands — commands · required.

commands:
- retry:
maxRetries: 2
commands:
- tapOn: "Refresh"
- assertVisible: "Updated"

Asks the configured model whether the assertion holds for the current screen. The bare form is optional: true — a failed check is a warning; { assertion, optional: false } fails the flow. Needs an AI connection: ai: in config.yaml or TAPSTEP_AI_* — see AI agents.

Keys: assertion — string · required; optional — bool.

commands:
- assertWithAI: "The cart shows exactly two items"
- assertWithAI:
assertion: "No overlapping text on the screen"
optional: false

Asks the model for obvious visual defects (clipped text, overlaps, broken layout). Optional by default; { optional: false } fails the flow.

Keys: optional — bool.

commands:
- assertNoDefectsWithAI

Asks the model to read something off the screen into into (default aiText); with pattern (regex) the answer must match — one retry, then the step fails.

Keys: query — string · required; into — string; pattern — string.

commands:
- extractTextWithAI:
query: "the 6-digit confirmation code"
into: code
pattern: "\\d{6}"
- inputText: "${code}"

Evaluates JS in the flow’s script context (one QuickJS context per run). Flow variables are globals; write output.name = … to publish ${output.name}; console.log is captured into the report. See Scripts.

Keys: script — string · required.

commands:
- evalScript: "output.total = Number(price) * 2"
- assertVisible: "${output.total}"

Runs a .js file (relative to the flow) in the same context; env adds variables for the script.

Keys: file — string · required; env — map.

commands:
- runScript:
file: scripts/prepare.js
env:
SEED: "42"

Calls an HTTP endpoint from the flow (http is an alias). - request: <url> is a GET; the map form takes method, headers, query, json or body, auth ({ bearer } / { basic: { user, pass } }), extract (name → JSONPath into ${vars}), assert or retryUntil (status as a code or "2xx", json equalities / { contains }, headers, schema file or inline), timeout ms (default 30000). retryUntil polls every second. ${} is substituted in the request, not in expected values. A relative url is joined to the flow’s http.baseUrl, and the header’s http: block also sets default headers and the cookie jar. With device: none a flow is pure API — see API testing.

Aliases: http

Keys: url — string · required; method — enum (GET | POST | PUT | PATCH | DELETE | HEAD | OPTIONS); headers — map; query — map; json — map; body — string; auth — map; extract — map; assert — map; retryUntil — map; timeout — ms.

Exclusive: one of json, body; one of assert, retryUntil; inside auth: one of bearer, basic.

commands:
- request:
method: POST
url: "${API}/login"
json:
email: "${EMAIL}"
password: "${PASSWORD}"
extract:
token: $.token
assert:
status: 200
- request:
url: "${API}/me"
auth:
bearer: "${token}"
retryUntil:
json:
$.status: active
timeout: 20000

Runs one statement against Postgres, MySQL or SQLite — seed a fixture before the UI, check what the UI wrote afterwards, clean up in after. db is the connection string (postgres://…, mysql://…, sqlite:<path>), usually ${DATABASE_URL} from .env; the flow header’s db: is the default for every step that omits it. Every step opens its own connection, so sqlite::memory: starts empty again in the next step — point sqlite: at a file. Exactly one of query (rows come back) and exec (affected count comes back) is required. params is a list of scalars — strings, numbers, booleans, null — bound positionally to the statement’s own placeholders ($1, $2 on Postgres, ? on MySQL and SQLite), so a value is never spliced into SQL text. A ${var} param carries an extracted value and binds as text, so on Postgres cast the placeholder when the column is not text (where id = $1::int). readOnly: true runs inside a transaction that is rolled back: a check that cannot change data. extract takes a JSONPath over the result — the rows array for query ($[0].id), { "affected": N } for exec — and assert takes rows, minRows, none, first (columns on the first row), contains (some row matches every column) and affected. timeout ms (default 30000) covers connecting and the statement. After the step ${sql.rows} or ${sql.affected} is available. The DSN is a secret: reports and logs show scheme://user:***@host/db. Writes are allowed — in CI, point db: at a read-only database user for check-only suites. See API testing.

Keys: db — string; query — string; exec — string; params — list of strings; timeout — ms; readOnly — bool; extract — map; assert — map.

Exclusive: one of query, exec.

commands:
- sql:
exec: "insert into coupons (code, percent) values ($1, $2)"
params: ["QA10", 10]
assert:
affected: 1
- sql:
query: "select status, total from orders where coupon = $1"
params: ["QA10"]
readOnly: true
extract:
total: $[0].total
assert:
rows: 1
first:
status: paid