Assertions

For AI agents: this page is Markdown at https://squiggle.sh/docs/flows/assertions.md. Every docs page is listed at https://squiggle.sh/llms.txt, and all of them are one file at https://squiggle.sh/llms-full.txt.

An assert checks what the app did. Without one, a flow only proves that each tap reached its control. Add an assert after every step whose result matters.

Why a flow needs asserts

A tap passes once it reaches the button, whether or not the app reacted. Try the same flow against a broken Add to cart button, with and without an assert:

YAML
- tap: { testID: add-to-cart }
  1. taptestID=add-to-cart — ⚠ no graph reaction (retried)
  2. ✔ PASS 1 of 1 steps

The tap reached the button, so the step passes. Nothing checked that the cart changed.

Read a failureStudio tour · 5 steps

What you can assert

YAML
- assert: { visible: { text: "1 item" } }- assert: { visible: { testID: cart-count, text: "3 items" } }- assert: { route: OrderConfirmation }- assert: { rerenders: { CartRow: { lt: 3 } } }- assert: { droppedFrames: { lt: 2 } }- assert: { log: { field: event, equals: order.submitted } }- assert: { action: { checkout.submit: { eq: 1 } } }- assert: { store: { useCart.mutationCount: { gte: 1 } } }
AssertPasses when
visible · notVisible · existsA control is on screen, not on screen, or anywhere in the app. Takes a selector.
routeThe current screen has this name or path.
rerendersA component rendered fewer (or more) times during the step, such as { CartRow: { lt: 3 } }.
droppedFramesFewer frames were dropped during the step.
storeA Zustand store exists, or changed a number of times: { store: { useCart.mutationCount: { gte: 1 } } }. The store is named by the variable it is declared as.
queryA TanStack Query has a status or was fetched a number of times.
logThe app logged a line: contains, regex, or a field of a logged event.
actionA named action ran a number of times. Needs defineAction in your app.
visualNothing overlaps, is clipped or is off screen (noOverlap, noClipping, onScreen), or the frame isn’t blank and text is readable (noBlankFrame, legibleContrast). To run the first three on every screen in light, dark and the largest text size, see squiggle flow --matrix.
memoryDeltaMemory grew less than a limit across the step before, such as a repeat in and out of a screen.
scrollAfter a scroll step: the list kept its frame rate, dropped few frames, and showed no tall blank band.
smoothAfter the step that starts a transition: the region never stalled, dropped few frames, and wasn’t driven from JavaScript.

Numbers take lt, lte, gt, gte or eq. The Flow syntax reference has the full form of each.

Performance as an assert

A leak, a list that stutters and an animation that freezes are failed steps, named by the screen and the step they happened in:

YAML
# Going in and out of a screen four times gives its memory back.- repeat:    times: 4    commands:      - tap: { testID: open-checklist }      - back: {}- assert: { memoryDelta: { lt: 20MB } }# A list scrolls without stuttering or showing empty rows.- scroll: { direction: down, target: { testID: checklist-list } }- assert:    scroll:      fps: { gte: 40 }      droppedFrames: { lte: 20 }      blank: { lt: 60 }# A transition never freezes and isn't driven from JavaScript.- tap: { testID: open-details }- assert:    smooth:      region: { testID: details-card }      gapFrames: { lte: 8 }      droppedFrames: { lte: 12 }      jsDriven: false
  • memoryDelta compares memory before and after the step right before it, so put it after a repeat that goes in and out of the screen. The limit is in bytes, or a size such as "1.5MB" (KB, MB and GB). It reads the app’s memory footprint, or its resident memory or JavaScript heap when that is all there is, and a failure names the screens the step entered. With fewer than two memory samples across the step, it reports pending: make the step longer, with more repeats.
  • scroll reads the list while the scroll step right before it runs. fps is the lowest frame rate sampled during the scroll. churn counts the components mounted and unmounted, which is high when a list throws rows away and rebuilds them. blank is the tallest empty band in the list, in points: rows that hadn’t rendered yet. It reads the list in list, a selector, or the scroll step’s target when you leave it out; with neither it reports pending. The assert fails when no list moved during the scroll, since a list that stood still shows no blank band.
  • smooth needs a region, and reads it every frame from the step right before it until the region holds still for a second, or for 6 seconds at most. gapFrames is the longest stretch inside the motion where nothing in the region changed. jsDriven: false fails when something in the region re-rendered 20 or more times a second while it moved: that motion stops whenever JavaScript is busy.

Take the numbers from your budgets

Leave a number out and scroll and smooth take it from the budget of the screen the assert runs on, so a limit lives in one place. A number you write in the flow wins. The "*" budget counts for every screen, and the one squiggle init writes sets fps ≥ 59 and droppedFrames ≤ 2, so an assert that leaves them out gets those limits. That is why the smooth example above sets its own droppedFrames.

AssertBudget it takes
scroll fpsframeP95Ms, as a floor: 1000 / frameP95Ms frames a second
scroll droppedFramesdroppedFramesPerInteraction
smooth droppedFramesdroppedFramesPerInteraction

scroll: {} takes both of its numbers from the budget. With no budget for that screen either, it reports pending. smooth with only a region checks that the region moved. The step’s result names the screen a number came from.

Check that every control takes its taps

--boundaries checks the edges of each labelled control on every screen a flow reaches. It hit-tests a point 1 pt inside and 1 pt outside each edge, and reports an edge where a tap inside lands on something else:

Terminal
squiggle flow settings --boundariesboundaries 2 screen(s) · 14 control(s) · 1 dead edge(s)  ✗ settings · save-button: the bottom edge: a tap at (196, 702), 1 pt inside it, reaches settings-footer    src/screens/Settings.tsx:88

A dead edge fails the run with exit code 1, like a failed step, and names the file and line of the control. The check needs the app launched with Squiggle attached (squiggle up); without it, it says no screen could be swept.

Assert on events, not on wording

A log message gets reworded; a field doesn’t. Log a structured event where the thing happens in your app:

TypeScript
import { logEvent } from '@squiggle.sh/react-native';logEvent('order.submitted', { orderId, total, currency: 'EUR' });

Then check its fields in the flow:

YAML
- assert: { log: { field: event, equals: order.submitted } }- assert: { log: { field: currency, equals: EUR } }

This keeps passing when someone changes the message, and fails when the order stops being submitted. contains and regex work on any line your app already prints.

A goal for the whole flow

goal takes the same checks as assert, and is checked after the last step. The flow fails if the goal isn’t met, even when every step passed:

YAML
flow: checkoutgoal: { route: OrderConfirmation }steps:  - tap: { testID: checkout }  - tap: { text: Pay now }

Propose asserts from runs that passed

Squiggle can write asserts for what your screens have always shown. It reads the recordings of agent runs that passed, counts on how many visits to each screen a control with a testID was there, and proposes an exists assert for each one that was there on at least 90% of visits. Each assert goes into the shortest flow that reaches its screen, right after the step that confirms the flow got there, with the recordings it was seen in above it:

Terminal
squiggle contracts propose          # print the proposalsquiggle contracts propose --write  # add the asserts to your flowssquiggle contracts propose --pr     # and open a pull request with them
YAML
  - waitFor: { route: agent-lab, timeout: 4000 }  - assert: { route: agent-lab }  # contracts below were observed on a settled screen  - waitFor: { commitQuiet: true }  # contract: held on 54 of 54 green visits to agent-lab (intent-mvc2lphx-2-dann, …)  - assert: { exists: { testID: add-to-cart } }  - assert: { exists: { testID: checkout } }

Without --write or --pr, nothing changes. --pr commits only the flows it changed, on a new branch, and opens the pull request with the gh CLI. You merge it yourself. Run the changed flows before you merge: a control that was there on 9 visits in 10 can be missing on the tenth.

--min-confidence 1 proposes only what was there on every visit. A screen needs at least 3 recorded visits before anything is proposed for it. Squiggle leaves out controls your flow already checks, controls from the screen underneath, controls with only a label, and flows that are meant to fail. Screens no flow confirms it reached are counted at the end of the proposal, since there is no flow to put their asserts in.