Skip to content

Flow Reference ​

Experimental

tapflow's AI Automation axis — the MCP server and the flow runner — is experimental. The manual QA dashboard is the mature, production path; this axis is additive and 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.

Two QA axes

tapflow has two QA axes. The browser dashboard is the manual QA axis, where your team tests by hand; the flow runner covered here is the automated QA axis, 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 has three top-level keys.

yaml
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 }
KeyRequiredDescription
nameNoName shown in reports. Defaults to the file name.
appIdConditionalBundle id of the app under test. Required when clearState is used without an argument.
stepsYesThe 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.

StepFormBehavior
clearStatekeyword or clearState: <bundleId>Resets the app data. The keyword form uses the top-level appId.
launchAppkeywordLaunches the build under test.
tapOnselectorTaps the center of the matched element.
inputTextstringTypes text into the focused input field.
pressKeykey namePresses 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.
scrollkeyword or scroll: <direction>Scrolls the screen. The bare keyword scrolls down.
openUrlURL stringOpens a deep link or URL.
assertVisibleselectorWaits until the element appears; fails if it does not.
assertNotVisibleselectorWaits 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.

yaml
# 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. Once a stage matches, later stages are not tried.

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.

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 where role alone 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.

yaml
# uses the top-level appId
- clearState

# names a different bundle
- clearState: com.other.app

Running flows ​

sh
tapflow flow run .tapflow/flows/login-smoke.yaml

You can pass several files at once.

sh
tapflow flow run .tapflow/flows/login.yaml .tapflow/flows/checkout.yaml
OptionDescription
--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-installSkip 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-recordDo not record this run on the relay.

The launchApp step takes no argument and launches the build 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.

Run records ​

With a token, the runner records the run on the relay: each flow's pass or fail and steps, a screenshot of the screen each flow failed on or ended on, the device it ran on, 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:write scope 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.

CodeMeaning
0All flows passed
1At least one product failure, including a run with both product and environment failures
2Environment/config error before execution, or every failed flow was environmental
130Cancelled by SIGINT (Ctrl+C, or a cancelled GitHub Actions job)
143Cancelled 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.

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.

text
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.

yaml
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.

Released under the MIT License.