Writing flows
A flow says what a journey means, not where it happened. Selectors resolve against the live runtime graph and the accessibility tree, so a moved button re-resolves instead of going red — and the engine writes the repair back into the file so you review it in a diff.
Structure
flow: checkout happy path # the canonical name key (required)appId: com.anonymous.example # optional; inherited by sub-flowstags: [smoke, checkout] # optional labelsenv: # optional flow-level variables USER: guest@acme.comprerequisite: # optional starting state route: Home reset: autohealing: adaptive # strict | adaptive (default) | lossy-fallbacksteps: - tap: { testID: product-1 } - type: { into: { testID: email }, value: "${USER}" } - tap: { text: Checkout } - waitFor: { route: OrderConfirmation } - assert: { route: OrderConfirmation }The header keys
flow(required)- The canonical name. It is what
squiggle flow checkoutmatches, so name a file for what it proves —user_registration_happy_path, nottest3. appId- The bundle identifier to drive. Inherited by every sub-flow, so a composed journey declares it once.
tags- Labels carried on the flow, and the field Maestro’s
tags:maps onto. They travel with the file and are readable from the library; the CLI selects a suite by directory rather than by tag. env- Flow-level variables, interpolated as
${VAR}anywhere a string appears. Parameterise test data here rather than hard-coding it; a caller can then override it throughrunFlow. prerequisite- The state the flow assumes. See Starting state and auto-correct below — this is the key that makes a flow repeatable rather than only working the first time.
healing- How hard the engine tries to re-resolve a selector that no longer matches. Three modes, below.
What a good flow looks like
Keep them modular — login → add_to_cart → checkout, composed with runFlow rather than written as one ninety-step file. Use waitFor for dynamic content instead of fixed sleeps. And after any tap whose effect must not be silent, assert the effect:
Selectors
A selector resolves to exactly one node. The fields, in the priority order self-healing uses to rewrite them:
- testID
- Explicit and stable. Prefer it — it is the only selector that survives a copy change and a redesign at once, and it resolves on the fast in-process accessibility path in about three milliseconds.
- source
File.tsx:42— where the component is declared, stamped at build time by the babel plugin. Survives renames of everything visible.- component · role · label · text · route
- Progressively weaker.
textis the easiest to write and the first thing a copy edit or a translation breaks.
Disambiguating a match
nth (1-based), index (0-based, for Maestro parity) and within narrow a selector that would otherwise hit several nodes. Prefer within: an ordinal silently points somewhere else the day a row is inserted above it.
- tap: { testID: sign-in } # strongest- tap: { source: LoginForm.tsx:42 } # where it is declared- tap: { text: Add, within: { testID: cart-row }, nth: 2 }- tap: { point: "50%, 50%" } # last resortTwo planes answer, not one
The runtime graph answers for anything React rendered. The host’s accessibility tree answers for what it cannot see — native tab bars, system controls, a label that lives on a child view. Both are consulted, which is why a selector for a native tab works at all, and why an assert against a live counter resolves against the current label rather than the one captured at mount.
Coordinates are the safety net, never the plan
point takes viewport percentages or logical points. The recorder will also write a positional at: fallback beside a selector, but only when the semantic selector it found was weak — a role-only or bare-container hit, like a UITabBar tab. A confident testID records no at.
The selector always stays primary, because a coordinate cannot self-heal and only works over real HID. If a control needs one, that is a signal to instrument it rather than a result to keep.
Instrument for testability
Discovery is only as good as the accessibility tree. A bare Pressable wrapping an icon and a string exposes them as two unrelated elements, so a tap on the icon cannot be addressed by name. One role and one label make the whole control a single named thing:
// One labelled control instead of an icon and a string that// happen to sit next to each other.<Pressable accessibilityRole="button" accessibilityLabel={title} testID="section-toggle"> <Chevron /> <Text>{title}</Text></Pressable>The recorder marks low-confidence captures as (weak fallback) in its status line, and squiggle legibility --fix finds the candidates across the app and inserts the identifiers, with the source lines to review.
Steps
Every verb the engine understands:
- tap · doubleTap · longPress
- Tap gestures, by selector or by
point/at. Delivered in-process by default, with a per-tap real-HID fallback. - type: { into, value, submit? }
- Focus and type, optionally followed by a submit key. In-process runs use a single
focusTyperound trip that polls for first-responder server-side instead of sleeping for the keyboard. Note that it appends — seeprerequisite.reset. - swipe · scroll: { direction, amount? }
- Directional gestures over real HID.
- scrollUntilVisible: { element, direction, maxScrolls?, mode? }
- Scroll until a target is reachable.
mode: gesture(default) drives deterministic dwell-drags;mode: directdrives the enclosing scroll view server-side. See Speed & delivery. - navigate: { route }
- Imperative route navigation — the portable alternative to a tap whose only job is to get somewhere.
- waitFor: { route?, commitQuiet?, animationIdle?, networkIdle?, timeout? }
- Graph-driven waits.
animationIdleis backed by the injected dylib’s frame-hash probe;networkIdlewaits for zero in-flight HTTP(S). Both reportpendingrather than passing when the app is not injected. - network.stub: { urlMatch, status?, body? } | clear
- Answer matching requests locally, for deterministic offline replay. Needs a
--capture-netlaunch. - launchApp: { app?, relaunch?, clearState? } · stopApp · back · pressKey
- Lifecycle.
relaunch: ifNeeded(the default) skips the cold start when the app is already running and injected;relaunch: alwaysterminates and launches for a genuinely fresh process, and the host reattaches its graph socket afterwards. - openLink
- Open a deep link through the dylib’s in-process handler, with a
simctl openurlfallback.${VAR}interpolates. - runFlow · repeat · retry · branch · escalate
- Composition and control flow — see below.
- assert
- Behavioural assertions — see below.
Telling a step where it lands — expectRoute
A navigating tap can name the route it arrives at. Once that route is reached the engine skips the post-navigation transition tail instead of sleeping it out — dead time under deterministic replay. It is a pure speed optimisation: naming the wrong route costs you the tail, it does not change what runs.
- tap: { text: Checkout, expectRoute: OrderConfirmation }- navigate: { route: Home } # expectRoute defaults to the navigate targetThe recorder fills this in for you, from the route it observed the tap landing on.
Control flow
when is a modifier on any step; repeat, retry and branch take blocks. optional: true lets a step fail without failing the run, and label: renames a step in the report.
# when / optional / label are modifiers INSIDE a step body:# a step mapping has exactly one verb key.- tap: { text: Accept, when: { visible: { text: Cookies } } }- tap: { testID: dismiss, optional: true, label: close the promo }- repeat: times: 3 commands: - tap: { testID: load-more }- retry: maxRetries: 2 commands: - tap: { testID: flaky-submit } - assert: { route: Done }- branch: if: { visible: { text: "Signed in" } } then: - tap: { testID: continue } else: - runFlow: { file: login.yaml }branch is the same two-armed conditional the intent runtime compiles into — its if is any assert body, evaluated read-only. That is deliberate: a program a model writes and a flow a human writes are the same IR, so a program can be saved into your flow library and a flow can be run by an agent.
Assertions
The point of an assertion is to re-observe the app. Squiggle can assert on much more than what is on screen, because the graph is already recording it:
- assert: { route: checklist }- assert: { visible: { text: "${USER}" } }- assert: { rerenders: { CartRow: { lt: 3 } } }- assert: { droppedFrames: { lt: 2 } }- assert: { log: { field: event, equals: order.submitted } }- assert: { action: { checkout.submit: { eq: 1 } } }What you can assert on
- route
- The current screen route.
- visible · exists · notVisible
- Node presence — graph first, then the host AX tree, so dynamic text the graph only captured at mount (a live counter) still resolves against the current label.
- rerenders
- Render-count delta over the step window.
{ CartRow: { lt: 3 } }is a performance assertion you can put in CI. - droppedFrames
- Frame samples over the step window.
- store · query
- Zustand store presence and mutation counts; TanStack Query state (
status,fetchCount). - log
- Captured app-log lines since the step began —
contains,regex, or a structuredfield/equalspair. - action
defineAction-instrumented verbs invoked since the step began. The strongest proof available that an action had an effect rather than merely being dispatched.
Starting state and auto-correct
A replay assumes the app is at the flow’s starting state, and the previous run almost never left it there. prerequisite declares that state; the engine drives the app to it before step 1 and reports it separately, as a [pre] prerequisite: … line, so a correction can never flip the flow’s verdict.
prerequisite: route: Home # where the flow assumes it starts reset: auto # auto | scroll | back | relaunch app: com.acme.app # optional; defaults to the flow's own appIdThe reset strategies
auto(default)- Scroll to top, then back out to the route, then relaunch — escalating only while the target route has not been reached.
scroll·back- Force one cheap strategy and go no further.
relaunch- Runs unconditionally, even when the app is already on the target route — and that is the difference that makes a form flow repeatable.
typeappends, so a second run against a field still holding the first run’s text submitsguest@acme.comguest@acme.com; only a genuinely fresh process clears it. The tier terminates before launching (launching a running app merely foregrounds it) and the host re-establishes the graph plane afterwards, so the run sees the new instance.
In Flow Studio the same thing is the Auto-Correct toggle in the toolbar. Recording with it on captures the current route into prerequisite for you.
Network stubbing
With --capture-net, a pass-through URL protocol sits in front of the app’s HTTP stack. That makes waitFor: { networkIdle } real, and lets a flow answer requests locally so a journey is deterministic offline:
- network.stub: urlMatch: "**/api/orders" status: 200 body: '{"orders":[]}'- tap: { testID: refresh }- waitFor: { networkIdle: true }- assert: { visible: { text: "No orders yet" } }- network.stub: clearHealing
When a selector no longer resolves, the engine ranks the nodes that could plausibly be the same control, rewrites the selector, and reports what it did — healed {field}: from → to (score X). Three modes:
- strict
- No rewriting. A moved control is a failure. Use in CI when you want the test to catch the move.
- adaptive (default)
- Heal when the match is confident, and rewrite the flow file in place so the repair lands in your next diff.
- lossy-fallback
- Heal aggressively, including down to weaker selector classes. Useful while a UI is churning; noisy afterwards.
A run reports what it survived as well as its verdict. A green run with four heals is not the same as a green run with none, and the report says which you got.
Reuse
A flow can call another with runFlow, passing env to override its defaults. Give each sub-flow defaults that let it run standalone — then squiggle flow login works on its own, and the parent still controls the credentials.
- runFlow: file: login.yaml env: { USER: admin@acme.com, PASS: hunter2 }Start from a recording
Writing YAML from scratch is the slowest way to get a flow. Drive the journey by hand and convert it:
squiggle record --out checkout.sqrecsquiggle flow --from-recording checkout.sqrec --out flows/checkout.yamlThe generated file is a draft, and it says so — including a comment listing the taps nothing durable identified, with their coordinates. That list is the honest output of the exercise: those controls have no testID and no accessibility label, and no amount of cleverness will make a selector for them.
Or let the crawler find the journeys
squiggle crawl drives the app on its own to surface crashes and coverage, and to seed flows. Per screen it computes a structural signature from the graph — route plus interactive handles — to deduplicate, fills text inputs with format-plausible values, taps a seeded candidate, descends when a tap reaches a new screen and backs out when one is exhausted.
squiggle crawl --seed 7 --max-nodes 50 --max-depth 5 --duration 30It is bounded by --max-depth, --max-nodes and --duration, so it always halts; it exits non-zero if it observed a crash; and its deny-list refuses destructive verbs (“logout”, “delete”, “pay”, “checkout”…) by default. The seed is a random number, so a crawl is reproducible. Flow Studio runs the same thing from its Crawl button and loads the discovered flow.
Coming from Maestro
The grammar is deliberately close, and index, point, runFlow, repeat, retry, optional, label, env, tags and appId all mean what they mean there.
What is not supported is reported, never silently passed
Relational selectors (below, above, leftOf), hierarchy matchers (containsChild), state matchers (enabled, checked), eraseText, hideKeyboard, clearState, setLocation and the rest of the device-farm surface are parsed and then reported as unsupported. There is no JavaScript engine, so runScript, evalScript and assertTrue have no equivalent — the graph assertions above are the answer instead.
The distinction that matters: none of those quietly count as green. A migrated suite tells you exactly which parts of it are not actually being tested.
What Squiggle adds
Self-healing selectors, and behavioural assertions read from the runtime graph — rerenders, droppedFrames, store, query, action — rather than only what is on the screen. A UI-only runner cannot see any of it.
Running one
squiggle flow checkout # by name, from .squiggle/flows/squiggle flow ./flows/checkout.yaml # by pathsquiggle flow - < generated.yaml # from stdinsquiggle flows ./flows/smoke # a directory, sharded across simssquiggle flow checkout --junit results.xml # native CI test resultssquiggle watch checkout # re-run on every file savesquiggle watch is the payoff of a warm session: it re-runs against the same connection rather than paying an app handshake per iteration. A save during a run marks the loop dirty and re-runs once at the end instead of interrupting, because a flow killed mid-tap leaves the app on an arbitrary screen and the next run then fails for a reason unrelated to your edit.
Flows are portable across surfaces by construction: the same file runs from the CLI, from Flow Studio in the desktop app, and from an agent via squiggle_run_flow — because all three drive the same engine rather than three implementations of it.