Flow Reference
Experimental
tapflow's AI agents (the MCP server) and Automation (the flow runner) are experimental. The manual QA dashboard is the most mature part of tapflow; these two are still maturing. Expect rough edges, especially in selector matching and in timing right after an app launches.
A flow is a UI test scenario written in YAML, replayed deterministically by tapflow flow run with no LLM calls. The same flow always runs in the same order from the same input, so it is idempotent — and it costs nothing in API calls in CI.
Three ways to use tapflow
There are three ways to use tapflow, all on the same devices and sessions: Manual QA in the browser dashboard, where your team tests by hand; AI agents, which drive a device through MCP; and Automation, the flow runner covered here, whose main stage is CI/CD.
Flows are not a language you hand-write from scratch — they are an artifact a coding agent generates or a recording produces. To author them with an agent, see MCP in CI/CD.
Flow file structure
A flow is made of these top-level keys.
name: login-smoke
appId: com.example.app
steps:
- clearState
- launchApp
- assertVisible: "Sign in"
- tapOn: { id: "com.example.app:id/email" }
- inputText: "user@example.com"
- pressKey: Enter
- tapOn: "Sign in"
- assertVisible: { label: "Orders", timeout: 15 }| Key | Required | Description |
|---|---|---|
name | No | Name shown in reports. Defaults to the file name. |
appId | Conditional | Bundle id of the app under test. Required when clearState is used without an argument. With --build, it is compared with that build's bundle id (Choosing flows). |
tags | No | A list of tags. Use them to pick flows with --include-tags and --exclude-tags. A tag cannot contain a comma. |
steps | Yes | The list of steps, run top to bottom. |
Coordinates are normalized 0–1 everywhere. Selectors point at screen elements directly, so you will rarely write pixel coordinates into a flow.
Steps
The vocabulary is deliberately small. These ten cover most scenarios.
| Step | Form | Behavior |
|---|---|---|
clearState | keyword or clearState: <bundleId> | Resets the app data. The keyword form uses the top-level appId. |
launchApp | keyword or launchApp: { arguments, env } | Launches the build under test and waits for its screen to settle: the role, label and id of every UI tree element unchanged for a second, so a splash screen is not taken for the first screen. A screen with no UI tree elements, such as a game, moves on after that second too. A screen whose labels keep changing moves on 3 seconds after it was first read, and one that cannot be read at all after the default per-selector wait. The wait never fails the step. A splash that stays unchanged for longer than a second can still be taken for the first screen. To pass launch arguments, see Launch arguments. |
tapOn | selector | Taps the center of the matched element. |
inputText | string | Types text into the focused input field. |
pressKey | key name | Presses a keyboard key (Enter, Backspace, Escape, …). |
swipe | { from, to, durationMs? } | Swipes between two points. Coordinates are 0–1. durationMs is milliseconds as a number, default 300, max 2147483647. Fractional values (e.g. 250.5) are accepted. |
scroll | keyword or scroll: <direction> | Scrolls the screen. The bare keyword scrolls down. |
openUrl | URL string | Opens a deep link or URL. |
assertVisible | selector | Waits until the element appears; fails if it does not. |
assertNotVisible | selector | Waits until the element disappears; fails if it does not. |
There is no fixed sleep step. Waiting is always condition-based. To wait for a transition, give assertVisible a timeout.
Selectors
tapOn, assertVisible, and assertNotVisible target an element with a selector, which takes three forms.
# a single string — resolved in the order below
- tapOn: "Sign in"
# explicit identifier
- tapOn: { id: "com.example.app:id/login" }
# explicit label + wait time (seconds)
- tapOn: { label: "Sign in", timeout: 20 }
# narrow a shared label by element kind
- tapOn: { label: "New Orders", role: button }
# a label-less row: pick the Nth of its kind (0-based)
- tapOn: { role: cell, index: 2 }A bare-string selector resolves in the order exact identifier → exact label → partial label → exact placeholder. Once a stage matches, later stages are not tried.
A placeholder is the hint text an empty input field shows. An input field with neither a label nor an identifier can be found by its placeholder. Because the placeholder is the last stage, an element whose label is the same text matches first. A placeholder never matches in part. On iOS the field can still be found by its placeholder after a value is typed into it. A label: selector looks at labels only.
If a tapOn selector matches more than one element, it fails immediately and lists the candidates. It never picks the first one implicitly, so an ambiguous selector fails visibly instead of quietly tapping the wrong place. assertVisible checks presence only, so it passes when at least one element matches.
When a tapOn fails because its selector matched nothing or more than one element, the runner prints up to three lines to try instead, taken from the screen at the moment it failed: try: tapOn: "Sign in". Each one is a selector checked to tap exactly that one element on that screen, and it is kept in the run record too. An input field is suggested by its id or its placeholder, never its label, so what was typed into it is not shown. An image is suggested only when its name matches the selector's, ignoring case, spacing and punctuation, because an image's name is usually an asset name rather than text on the screen. A failed assertVisible gets no suggestions: the expected text being missing can mean the app changed, and swapping in similar text would hide the regression.
To resolve an ambiguous match, add a disambiguator to the object form:
role— narrow by element kind (button,text,cell,input, …), e.g. when a button and its inner text share a label.index— 0-based; pick the Nth remaining match. Useful for label-less, id-less rows whererolealone still matches several.
The object form needs at least one of id, label, or role; role/index refine that set.
timeout is in seconds and defaults to 10 (max 2147483.647, about 24.8 days). You can set it per selector. Values that round to 0ms (e.g. 0.0004s) are rejected.
Resetting state
clearState wipes the app data so every run starts from the same state. It uses pm clear on Android and empties the app data container on iOS. The installed binary survives either way, so you can follow clearState with launchApp.
tapflow flow run also resets the device before the flows start, so a run does not begin from an earlier run's data, keychain or permissions. clearState is still how a flow resets its app between its own steps. What the run resets is listed under tapflow flow run.
If the app is already running, launchApp does not restart it: it brings the app forward on whatever screen it was last on. A launchApp with launch arguments is the exception. A flow can then start from a screen an earlier run or a manual test left behind, so a flow that must start from the app's first screen puts clearState before launchApp. clearState stops the app before wiping its data.
# uses the top-level appId
- clearState
# names a different bundle
- clearState: com.other.appLaunch arguments
launchApp can pass arguments to the app it launches. You can turn on a debug option that only a launch argument enables, such as Firebase DebugView, or change an app flag such as a staging server address, without a new build.
- launchApp:
arguments:
FIRDebugEnabled: true
apiHost: staging.example.com
AppleLanguages: [en]
env:
API_TOKEN: secret-value| Key | Values | iOS | Android |
|---|---|---|---|
arguments | string, whole number, true/false, list of strings | Passed after the bundle id as -key value. The app reads them with UserDefaults and ProcessInfo.arguments | Passed as intent extras. The app has to read them with getIntent().getExtras() |
env | string | Set in the app process's environment | Not supported. The step fails if you pass it |
- With arguments or env, the app is stopped and started again even if it is already running, because a running app does not read its launch arguments again.
- On Android a value's type decides the extra's type: a whole number is an int (a long when it does not fit),
true/falsea boolean, a list a string array. Quote a number the app reads as a string, as inport: "8080". On iOS every value arrives as a string. - Fractions are refused, because older Android images have no double extra. Quote a fraction to pass it as a string.
- Write a key without the
-that iOS adds. A key such as-FIRDebugEnabledis refused when the flow loads. - Put secrets in
env. Run records and failure messages show argument values as they are, and env values as***. - Environment names tapflow depends on are refused: names starting with
DYLD_,TAPFLOW_orSIMULATOR_,HOME,TMPDIRand a few more. - Third-party SDKs turn on their debug options differently on each platform. Firebase DebugView, for example, turns on with
FIRDebugEnabled: trueon iOS, but on Android no intent extra turns it on: runadb shell setprop debug.firebase.analytics.app <package>on the device instead. - The relay and the agent on the device's Mac both need a version that supports launch arguments. If either is older, the step fails rather than launching without them.
- The relay can add launch defaults for the app from
tapflow.config.json. When the step and the defaults set the same key, the step's value is used. In the run record, the keys of the defaults a launch applied follow the step's own options, without their values:launchApp(apiHost="staging.example.com"; config: FIRDebugEnabled, env API_TOKEN).
Running flows
tapflow flow run .tapflow/flows/login-smoke.yamlYou can pass several files at once.
tapflow flow run .tapflow/flows/login.yaml .tapflow/flows/checkout.yaml| Option | Description |
|---|---|
--relay <url> | Relay URL (default ws://localhost:4000) |
--token <token> | Personal access token (PAT) for a remote relay (or the TAPFLOW_TOKEN env var, or the token file). Use an API-type token from the dashboard (view, builds:write) |
--device <name> | Target device by name. Boots it when shut down. |
--session <id> | Session id of the target device. Use it when several devices share a name and --device cannot pick one. |
--build <id> | Build under test. Installed before the run; the launchApp step launches it. |
--no-install | Skip installing the --build build before the run. Use it to re-run against a build that is already installed. |
--junit <path> | Write a JUnit XML report to this path. |
--artifacts <dir> | Failure-screenshot directory (default .tapflow/artifacts) |
--timeout <seconds> | Default per-selector wait (default 10, max 2147483.647) |
--no-record | Do not record this run on the relay. |
--include-tags <tags> | Run only flows tagged with at least one of these (comma-separated). |
--exclude-tags <tags> | Skip flows tagged with any of these (comma-separated). Wins over --include-tags. |
The launchApp step names no build and launches the one passed via --build. That keeps the build id out of the flow file, so the same flow runs against a fresh build on every CI run.
When the run starts, the runner prints a dashboard link on a watch this run: line. Open it to watch, live, the flow driving the device; the watch ends when the run does. In CI the line is not printed (detected from the CI environment variable, Jenkins's JENKINS_URL or Azure Pipelines' TF_BUILD), because the link shows the relay's address and would stay in a public repository's log. How the address is chosen, and who can watch, are the same as for the MCP server, with the --relay address in place of TAPFLOW_RELAY_URL.
Choosing flows
A flow that does not fit this run is skipped rather than run. There are two cases.
- A flow for another app: with
--build, each flow'sappIdis compared with that build's bundle id. When they differ, the runner prints both ids, as in– login (skipped: flow is for com.example.app, build 42 is com.example.app.debug), and skips the flow. You can pass a directory that mixes several apps' flows, and only the ones for the build run. A flow with noappIdruns against any build. - A flow the tags filter out:
--include-tags smoke,loginruns only flows that carry at least one of the two.--exclude-tags flakyskips flows that carry it, and when the two overlap,--exclude-tagswins.
A skipped flow does not count as a failure. The last line counts it separately, as in 1/1 flows passed, 1 skipped. When no flow runs at all, the run stops with exit code 2 before choosing a device, so a run that tested nothing cannot read as a pass. A build id the relay does not have stops the run the same way.
Comparing appId needs a token that can read the build: an API-type token (view, builds:write). With no token, or a view token, the runner prints an app check skipped line and runs every flow whatever its appId. If you run a flow against a build whose bundle id carries a suffix, such as a debug build (com.example.app.debug), set appId to that build's id or remove it.
Run records
With a token, the runner records the run on the relay: each flow's pass, fail or skip, why a flow was skipped, its steps, a screenshot of the screen each flow failed on or ended on, the device it ran on and what it was reset of, and the build passed with --build. In CI it also records the provider, branch, commit and a link to the CI run. When the relay created the record, the runner prints its id on a recorded as run line at the end of the run, followed outside CI by a link to the run's dashboard page. The records are on the dashboard's Flow Runs page.
- Recording never changes the result or the exit code. If the relay does not answer or refuses the record, the runner prints one warning line and records nothing more for that run. A screenshot the relay cannot take (for example, one over 5 MB) gets its own warning, and the rest of the run is still recorded. At the end of a run it waits at most 10 seconds for the record to finish sending. Capturing a passed flow's last screen takes one screenshot before the next flow starts, at most 5 seconds.
- While recording, the runner also sends each flow's video. It cuts the device stream it already receives, so a flow never waits on the video being sent. On iOS it asks the agent for an H.264 stream for this, and when the agent is set to send JPEG (
TAPFLOW_IOS_CODEC=jpeg) no video is kept. If video cannot be sent, the runner prints one warning line and the record carries on. At the end of a run the record is sent first, and video gets what is left of the 10 seconds. The relay keeps a passed flow's video for 3 days by default, and when all videos together pass 20 GB it removes passed flows' videos first, oldest first. - Recording needs a token with the
builds:writescope whose owner is not a Viewer. An API-type token works. - A run without a token against a relay on the same Mac is not recorded, and one line says so.
- A run's record is deleted with its build; a record of a run with no build is deleted after 7 days.
Exit codes
The exit codes are a contract so CI can tell what happened.
| Code | Meaning |
|---|---|
0 | All flows passed |
1 | At least one product failure, including a run with both product and environment failures |
2 | Environment/config error before execution, or every failed flow was environmental |
130 | Cancelled by SIGINT (Ctrl+C, or a cancelled GitHub Actions job) |
143 | Cancelled by SIGTERM (what kill sends by default) |
Distinguishing 1 from 2 matters: a product failure (1) and an infrastructure problem (2) should be handled differently on a CI dashboard. A run with both kinds stays at 1 so an infrastructure blip cannot hide a regression.
A run cancelled while its flows are running leaves its session and, when it is being recorded, marks the record cancelled before it exits. A second signal exits at once.
A failed flow leaves a screenshot from the point of failure in the artifacts directory, and with --junit each flow is recorded as one testcase. A skipped flow is a testcase with <skipped>.
File location convention
Keep flow files in your app repository under .tapflow/flows/. The runner accepts any path, so this is not enforced, but the CI examples and docs default to it.
your-app/
├── .tapflow/
│ └── flows/
│ ├── login-smoke.yaml
│ └── checkout.yaml
└── ...A JSON Schema for editor autocomplete ships with the @tapflowio/flow-runner package at schema/tapflow-flow.schema.json.
Running in CI
With a relay and agent always on a self-hosted Mac runner, a CI job only has to replay the flows. No LLM is involved on the replay path, so there is no API cost.
name: Flow smoke test
on:
workflow_dispatch:
push:
branches: [main]
jobs:
smoke:
runs-on: [self-hosted, macos]
steps:
- uses: actions/checkout@v4
- name: Run flows
env:
TAPFLOW_RELAY_URL: ${{ secrets.TAPFLOW_RELAY_URL }}
TAPFLOW_TOKEN: ${{ secrets.TAPFLOW_TOKEN }}
run: |
tapflow flow run .tapflow/flows/*.yaml \
--relay "$TAPFLOW_RELAY_URL" \
--device "iPhone 16 Pro" \
--build "$BUILD_ID" \
--junit report.xml
- name: Publish report
if: always()
uses: actions/upload-artifact@v4
with:
name: flow-results
path: |
report.xml
.tapflow/artifacts/The exit-code contract makes the job fail when a step fails (1 or 2). if: always() collects the failure screenshots and the JUnit report even on a failing run.
Relationship to run_flow
The same flow engine runs through the MCP run_flow tool. A coding agent authors a scenario once while exploring the app, then replays that flow deterministically afterward. Exploratory work goes through the individual MCP tools; verified scenarios replay through run_flow — a hybrid of the two.
Authoring by an agent and replaying with a deterministic runner is covered in more depth in MCP in CI/CD.