ProbeScript Syntax
ProbeScript is a natural language test syntax with indent-based blocks (like Python). Test files use the .probe extension.
Every test starts with test followed by a quoted name, with steps indented below:
test "user sees welcome screen" open the app wait 3 seconds see "Welcome" don't see "Error"Add tags with @ after the test declaration:
test "critical login flow" @smoke @critical open the app tap "Sign In" see "Dashboard"Run tagged tests with probe test tests/ --tag smoke.
Selectors
Section titled “Selectors”ProbeScript supports multiple strategies for identifying widgets:
| Selector | Syntax | Example |
|---|---|---|
| Text match | "text" |
tap "Submit" |
| Widget key | #keyName |
tap #loginButton |
| Widget type | <TypeName> |
tap <ElevatedButton> |
| Ordinal | 1st "Item", 2nd "Item" |
tap 2nd "Add" |
| Positional | "text" in "Container" |
tap "Edit" in "Settings" |
Text Input
Section titled “Text Input”type "hello@world.com" into "Email"type "secret123" into the "Password" fieldAssertions
Section titled “Assertions”see "Dashboard" # text is visibledon't see "Error" # text is NOT visible (dont see "Error" also works)see 3 "Item" # exactly 3 matchessee "Submit" is enabled # widget statesee "Terms" is checked # checkbox statesee "Price" contains "$9.99" # partial text matchGestures
Section titled “Gestures”tap "Button"double tap "Image"long press "Item"swipe leftswipe up on "Card"scroll downscroll up on "ListView"scroll down until "Rate this app" appears # keep scrolling until it is on screenscroll until #share_button is visible # direction defaults to downdrag "Item A" to "Item B"scroll down reveals later content (the list offset grows); scroll up goes back toward the top.
scroll ... until <target> appears scrolls half a viewport at a time — lists build rows lazily, so
the target only exists once enough has been scrolled — and then brings the target fully on screen.
It stops early when the list can’t move further, and fails with the visible texts if the target never
shows up. It is the equivalent of Maestro’s scrollUntilVisible. Use on "List" to pick which
scrollable when a screen has several.
Matching text in several languages
Section titled “Matching text in several languages”Prefer #key selectors (locale-independent). When you select by visible text, matching is a case-sensitive substring by
default. Run with --match-loose (or defaults.match: loose) to fold case, accents/diacritics, typographic
apostrophes and dashes, full-width forms and whitespace on both sides, so one script tolerates Änderungen speichern /
anderungen SPEICHERN, Don’t Allow / Don't allow, and full-width digits. Scripts that differ per language use
see any of "Save", "Speichern", "Guardar". Scripts with Indic or Thai text are not stripped of their vowel signs.
Targets that are off screen
Section titled “Targets that are off screen”tap, type, long press, double tap and clear scroll a target that exists in the widget tree into view first, like a
user would. A lazy list only builds the children near its viewport, so a target beyond that (a chip far to the left of a
horizontal list) does not exist yet: scroll toward it with scroll left until "All" appears, then act. see and
don't see only count what is on screen.
Text matching is by substring
Section titled “Text matching is by substring”A quoted text selector (see "0 ml", tap "Save", wait until "Done" appears) matches any widget whose text
contains it: see "0 ml" passes while only “250 ml” is on screen. For an exact check use an anchored pattern,
see "0 ml" matching "^0 ml$" (some matched widget’s text must match the regular expression), or select by key
(see #counter_text). Before 0.16.8 the matching pattern was accepted but never applied.
For zero-state and count checks (“0 ml”, “3 items”, “No items”) prefer the anchored form, because a substring match also
passes on longer text such as “10 ml”. probe lint warns, without failing, about a see or wait until ... appears on
such a text that has no matching pattern. A matching pattern anchored at both ends (^...$) silences the warning; any other pattern is still a substring match and is still flagged.
Wait Commands
Section titled “Wait Commands”wait 5 secondswait until "Dashboard" appearswait until "Loading" disappearswait until any of "Got it", "Login", "Home" appears # whichever shows firstwait until "0 ml" appears matching "^0 ml$" # exact text (selectors match substrings)wait for the page to loadwait for network idlewait for idlewait for idle waits until route transitions (dialogs, sheets, pages closing or opening) have finished
and no frames, animations or HTTP requests are pending. Use it after closing a dialog or bottom sheet
instead of a fixed wait 2 seconds. tap also waits (up to 2 seconds) for an in-flight route transition
before it fires.
When a step times out, the error names the line, the step, the timeout and what was on screen
(visible texts: [...], keys: [...]), so a failure no longer reduces to context deadline exceeded.
Conditionals
Section titled “Conditionals”if "Accept Cookies" appears tap "Accept Cookies"With an else branch:
if "Welcome Back" appears tap "Continue"else tap "Sign In"repeat 3 times swipe left wait 1 secondRetry Blocks
Section titled “Retry Blocks”retry N times re-runs its whole block from the top on failure, up to N total attempts,
stopping at the first success — unlike repeat, which always runs every iteration:
retry 3 times tap "Submit" see "Success"Optional Steps
Section titled “Optional Steps”A trailing optional on tap/type/long press/double tap/clear/see attempts the step,
but logs a warning and continues instead of failing the test when it errors. Unlike if visible
(a pre-check that skips the step entirely when the target isn’t found), optional always tries:
tap "Rate this app" optionalsee "Promo banner" optionalDart Escape Hatch
Section titled “Dart Escape Hatch”For anything ProbeScript doesn’t cover natively, use a dart: block:
dart: final prefs = await SharedPreferences.getInstance(); await prefs.clear();HTTP Mocking
Section titled “HTTP Mocking”when the app calls POST "/api/auth/login" respond with 503 and body "{ \"error\": \"Service Unavailable\" }"when the app calls GET "/api/orders" respond with 200 and body "[]" after 3 seconds # slowwhen the app calls PATCH "/api/profile" respond with network failure # connection droppedMocks are effective (agent 0.22+): the app’s dart:io HTTP client receives them. They last for one test and survive restart the app.
Performance
Section titled “Performance”start measuring "checkout"tap "Pay"wait for response POST "/api/pay" status 200 within 10 secondsstop measuringsee memory below 300 MBsee memory growth below 20 MBsee cpu below 60 percentsee slow frames below 5 percentsee data transferred below 500 KBsee response POST "/api/pay" below 2000 msMeasures memory, frame timings and HTTP traffic (agent 0.23+) and CPU (Android, iOS simulator) between start measuring and stop measuring.
See Performance testing.
Time Budgets
Section titled “Time Budgets”tap "Export" within 5 secondswait until "Report ready" appears within 90 secondswait for response "/api/export" status 200 within 120 secondswithin N seconds|ms gives that one step its own timeout and implicit-wait window. See Timeouts, waiting and retries.
Backend Responses
Section titled “Backend Responses”wait for response GET "/api/orders" status 200see response "/api/me" json "data.plan" equals "pro"see response "/api/me" contains "premium"store response "/api/me" json "data.plan" as plan # then <plan>if response "/api/me" json "data.plan" equals "pro" see "Premium"see exactly 1 request GET "/api/orders"see no requests "/api/analytics/*"clear recorded requestsSee Testing against backend data.
Utility Commands
Section titled “Utility Commands”take screenshot "checkout_page" # save PNG to screenshots foldercompare screenshot "baseline" # compare against visual regression baselinedump tree # dump widget tree for debuggingsave logs # save app logspress enter # keyboard action key on the focused field (Maestro pressKey: Enter)go back # pop the current route (no-op + warning at the root; use "close the app" to exit)rotate landscape # rotate deviceshake # simulate device shake gesturelog "checkpoint reached" # print to test outputpause # 1-second pauseApp Lifecycle
Section titled “App Lifecycle”clear app data # wipe data and relaunchrestart the app # force-stop and relaunch (preserves data)kill the app # force-stop only (no relaunch)open the app # launch the app (CLI-side) and reconnectClipboard
Section titled “Clipboard”copy "user@example.com" to clipboardpaste from clipboard # stores result in <clipboard> variabletype "<clipboard>" into "Email" # use the pasted valueLocalized text from ARB files
Section titled “Localized text from ARB files”tap l10n "saveButton" # the text of key saveButton in the app's current languagesee l10n "title"l10n "key" is a string wherever a string works. It needs l10n.dir in probe.yaml; a key that does not resolve fails the run before
it starts. See Testing localized apps.
App Language
Section titled “App Language”set language "de" # relaunch the app in German (also "pt-BR", "ja", "ar", ...)see "Einstellungen"set language "system" # back to the device languageThe app is force-stopped and relaunched, so app state is lost (data on disk is kept). Android 13+ uses per-app locales; an iOS
simulator gets the language as a launch argument. Right-to-left languages (ar, he, fa, ur) flip the layout because
Flutter follows the locale. Not supported on physical iOS devices. See Testing localized apps.
Device Location
Section titled “Device Location”set location 37.7749, -122.4194 # set GPS coordinates (lat, lng)Simulate movement through an ordered route of waypoints over a duration:
travel to 37.7749, -122.4194 37.7849, -122.4094over 10 secondsThe device’s location moves through the waypoints in order, interpolated at roughly 1-second
intervals. over N seconds is optional (defaults to ~1 second per leg). Emulator/simulator only,
same as set location — skips with a warning on physical devices.
Device Media
Section titled “Device Media”add media "fixtures/photo.jpg" # seed a file into the camera roll/galleryUses adb push + a media-scanner broadcast on Android and simctl addmedia on iOS simulators —
the file becomes visible to image pickers. CLI-side only; skipped with a warning in cloud mode.
Note: this makes a photo available to pick — driving the native picker UI itself to select it
is tap native (Android, below).
External Browser and Deep Links
Section titled “External Browser and Deep Links”open link "https://example.com" # external browser via url_launcherverify external browser opened # assert url_launcher was calledopen link "myapp://profile/42" in the app # route via OS intent handling to the # app's own registered scheme insteadin the app (also into the app / in app) dispatches through the OS (am start -a VIEW /
simctl openurl), so a custom scheme or App/Universal Link registered by the app under test is
delivered to that app. On iOS Simulator this reliably works when the app is already running —
simctl openurl cannot cold-launch a terminated app. No such caveat on Android.
Native UI (Android)
Section titled “Native UI (Android)”Reach outside the Flutter widget tree into native, OS-owned UI — pickers, share sheets — matched against uiautomator’s text or resource-id:
tap native "Choose from Gallery"see native "IMG_0001.jpg"don't see native "Error"type native "wifi" into "Search settings"Android only (dispatched via uiautomator, no new dependencies). On iOS, tap native/type native error clearly rather than silently no-op’ing; see native reports “not found”. If the
native field is reached via a screen transition, add a wait step first — the same idiom used
after Flutter navigation.
Biometric Authentication
Section titled “Biometric Authentication”Drive Face ID / Touch ID / fingerprint prompts on the simulator or emulator. Skipped on physical devices.
enroll biometric # mark the device as having an enrolled face/fingerbiometric match # simulate a successful capture (unblocks a pending prompt)biometric no match # simulate a failed capture (triggers the failure path)Typical pattern — wraps a Face ID prompt with a happy and unhappy path:
before all tests enroll biometric
test "matching face unlocks" open the app tap "Sign in with Face ID" biometric match wait until "Dashboard" appears
test "non-matching face is rejected" open the app tap "Sign in with Face ID" biometric no match see "Authentication failed"On iOS, this posts the BiometricKit_Sim.faceCapture.match / .no-match
Darwin notifications (and the fingerTouch.* equivalents for Touch ID
devices), then sends probe.biometric_signal to the agent. On Android,
this calls adb -s <serial> emu finger touch <id> — fingerprint ID 1
is matching by convention (must be pre-enrolled in Settings before tests
run); any unregistered ID is no-match.
HTTP Calls
Section titled “HTTP Calls”Make real HTTP requests to APIs (runs on the CLI, not the device):
call GET "https://api.example.com/health"call POST "https://api.example.com/seed" with body "{\"env\":\"test\"}"call PUT "https://api.example.com/users/1" with body "{\"name\":\"updated\"}"call DELETE "https://api.example.com/sessions"Responses are stored in variables:
<response.status>— HTTP status code (e.g.,200)<response.body>— response body as a string
Data Generators
Section titled “Data Generators”Generate random data for form-heavy tests:
type "<random.email>" into "Email" # e.g., user_x7k2m@test.probetype "<random.name>" into "Name" # e.g., Alice Johnsontype "<random.phone>" into "Phone" # e.g., +1-555-042-7831type "<random.uuid>" into "Reference" # UUID v4type "<random.number(1,100)>" into "Age" # random int in rangetype "<random.text(8)>" into "Code" # random alphanumeric stringPermissions
Section titled “Permissions”allow permission "notifications"deny permission "camera"grant all permissionsrevoke all permissionsSee App Lifecycle for details on how these work across platforms.
To grant permissions once, before the first test, instead of in every test, pass --grant:
probe test tests/ --grant notifications,camera,locationAndroid grants via adb shell pm grant (including POST_NOTIFICATIONS); the iOS simulator grants the
services xcrun simctl privacy supports (camera, location, microphone, photos, contacts, calendar, sms).
iOS notifications have no simctl service. Since 0.16.0 probe answers the SpringBoard alert itself:
--grant notifications and allow permission "notifications" tap Allow when it appears (needs Xcode,
simulators only; see System dialogs).
System dialogs
Section titled “System dialogs”OS-level dialogs (permission alerts, the StoreKit “Sign in to Apple Account” sheet, Android permission dialogs) live outside the Flutter widget tree. These steps drive them:
tap "Allow" in system dialogtap "OK" in system dialog "Apple Account" # optional title filtertype "$PROBE_SANDBOX_PASSWORD" into system field "Password"see system dialog "Sign in to Apple Account"don't see system dialog "Notifications"wait for system dialog "Notifications" appears # or: disappearsdismiss system dialog # Cancel / Don't Allow / Not Now; no-op if nonesign in sandbox tester # StoreKit sandbox account; no-op if no sheet appearstap "Allow" in system dialog optional # any step: don't fail when no dialog shows upValues typed into a system field come from an environment variable ($NAME or ${NAME}) or a literal, and are
always masked in step output, reports and error messages. See System dialogs for
setup, the standalone probe system-dialog command and troubleshooting.
Conditional Actions
Section titled “Conditional Actions”Skip an action silently when the target widget is not found:
tap "Aceptar" if visible # tap only if present, skip otherwisetap "Cerrar" if visible # useful for dismissing optional dialogsclear "Search" if visible # clear field only if it existstype "text" into "Field" if visiblelong press "Item" if visibledouble tap "Element" if visibleThe if visible suffix works with tap, type, clear, long press, and double tap. If the widget is not found, the step is silently skipped (no error). Connection errors are still propagated.