# Assertions

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:

## 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 } } }
```

| Assert | Passes when |
| --- | --- |
| `visible · notVisible · exists` | A control is on screen, not on screen, or anywhere in the app. Takes a selector. |
| `route` | The current screen has this name or path. |
| `rerenders` | A component rendered fewer (or more) times during the step, such as { CartRow: { lt: 3 } }. |
| `droppedFrames` | Fewer frames were dropped during the step. |
| `store` | A 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. |
| `query` | A TanStack Query has a status or was fetched a number of times. |
| `log` | The app logged a line: contains, regex, or a field of a logged event. |
| `action` | A named action ran a number of times. Needs defineAction in your app. |
| `visual` | Nothing 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`](https://squiggle.sh/docs/run/settings). |
| `memoryDelta` | Memory grew less than a limit across the step before, such as a repeat in and out of a screen. |
| `scroll` | After a scroll step: the list kept its frame rate, dropped few frames, and showed no tall blank band. |
| `smooth` | After 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](https://squiggle.sh/docs/reference/flow) 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](https://squiggle.sh/docs/performance/baselines) 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`.

| Assert | Budget it takes |
| --- | --- |
| `scroll fps` | frameP95Ms, as a floor: 1000 / frameP95Ms frames a second |
| `scroll droppedFrames` | droppedFramesPerInteraction |
| `smooth droppedFrames` | droppedFramesPerInteraction |

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

> **What these need**
>
> `blank` and `smooth` read pixels, so they need an iOS app launched by Squiggle (`squiggle up`); anywhere else they report `pending`. Reading every frame costs the app a few dropped frames, and a busy Mac drops more, so set `droppedFrames` and `gapFrames` above what your app measures on a loaded machine. A freeze longer than a second reads as the motion ending.

## 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:

```shell
squiggle flow settings --boundaries
```

Output:

```text
boundaries 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:

```ts
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: checkout
goal: { 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:

```shell
squiggle contracts propose          # print the proposal
squiggle contracts propose --write  # add the asserts to your flows
squiggle 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.

> **Pending and unsupported never count as a pass**
>
> Some checks can’t be answered on every run. An `action` assert in an app with no `defineAction` reports `unsupported`; a `memory`, `network` or `gesture` assert reports `pending`. Neither counts as passed. To fail the run when a step or the goal is `unsupported`, run with `--strict-assert` or set `strictAssert: true` in the flow’s `config`.
