Speed & delivery

Most UI test runners are slow for two reasons: they sleep because they cannot tell when the app is idle, and they drive the device from outside because they have no way in. Squiggle has a graph and an injected agent, so it can do neither.

Three delivery planes

A step has to get from your YAML into the app. There are three routes, and a single run mixes them — the header of every report names which one it used:

Real HID
The Swift host synthesises genuine digitizer events against the booted simulator — the same path a human finger takes. Highest fidelity, works whether or not the app is instrumented, and slowest: every event is a round trip through CoreSimulator, and the app has to be animation-idle before iOS will even accept the touch.
In-process (the injected agent)
A dylib loaded into the app synthesises the touch inside the process —UITouch delivery, caret insert for typing, directcontentOffset for scrolling. A socket round trip is about 3 ms. Every operation keeps a per-op real-HID fallback, so a control the in-process path cannot reach still gets tapped.
Runtime invoke
Straight into the JS runtime, no device at all. This is what --headless selects, for CI with no booted simulator. No gestures and no coordinate taps — semantic steps only.

What each thing is worth

Selector resolution: ~82 process spawns → one round trip
Resolving a selector the graph cannot answer used to spawn an accessibility dump per attempt — a worst-case semantic resolve cost about eighty-two process spawns. A persistent host agent-session now serves dump/find/hit/settled over one long-lived connection, and when the app is injected it reads the in-process tree in roughly 3 ms. Injected resolves also take a server-side find pre-pass, so an off-screen target is a fast definitive miss rather than a sweep.
Direct scroll: 2.3 s → 17 ms
A gesture scrollUntilVisible is a loop of drag, re-measure, drag again. mode: direct asks the dylib to drive the enclosing scroll view’s contentOffset server-side, so the whole find → step → re-find loop is one round trip. Measured on the example app’s long list: 2.3 s to 17 ms.
Typing: one round trip instead of a tap, a sleep, and n keystrokes
The in-process focusType verb taps, waits for first-responder server-side, and inserts the text — replacing a focus tap, a fixed 250 ms wait for the keyboard to mount, and a HID event per character.
Settle-gated waits: sleeps become ceilings
With --fast-settle, the fixed transition sleeps become maximums. The interpreter polls the dylib’s settled probe and proceeds the moment the app is idle. On a fast screen that is the difference between waiting the full budget and waiting almost none of it.
Speculative resolve: overlap instead of add
--speculative-resolve pre-warms the next step’s selector during the current step’s settle window, so resolve latency overlaps dead time. The result is re-validated before delivery, so a stale pre-warm cannot drive the wrong element.

Every speed flag, in one place

--fast-settle
Turn the fixed transition sleeps into ceilings, polled against the dylib’s settled probe. Needs an injected app; without one the sleeps stay fixed.
--direct-scroll
Make mode: direct the default for every scrollUntilVisible. An explicit mode: on a step always wins over it.
--speculative-resolve
Pre-warm the next step’s selector during this step’s settle window.
--no-in-process
Force pure real HID for taps, typing and swipes. What you want when the thing under test is the touch pipeline.
--headless
Runtime-invoke only: no device, no simulator, semantic steps only. Gestures and coordinate taps are reported as unsupported rather than faked.
--capture-net
Put the pass-through URL protocol in front of the app’s HTTP stack, which is what makes networkIdle and network.stub real.

Combining them

The fast configuration for a suite that is testing behaviour rather than scroll physics:

Terminal
squiggle up                    # relaunch with the agent injected — do this firstsquiggle flow checkout \  --fast-settle \               # settle-gated waits instead of fixed sleeps  --direct-scroll \             # server-side scrolling by default  --speculative-resolve         # overlap the next resolve with this settle

In-process delivery is already on. --no-in-process turns it off and forces pure real HID for taps, typing and swipes — which is what you want when the thing under test is the touch pipeline.

Why gesture scrolling is still deterministic

The default mode: gesture is not a naive fling. It drives dwell-drags: a half-viewport drag held stationary before lift, so it lifts at zero velocity. No momentum means the content moves exactly the drag distance — nothing to decelerate through, nothing to overshoot — while still going through the app’s real touch pipeline. A target counts as reached only when its rect is fully inside the viewport and a hit-test at its centre actually reaches it, because an iOS 26 collapsed glass header can leave a “visible” row where a tap lands on the chrome.

Reading a run

The report

Every run prints per-step timings and closes with a slowest-steps chart, so the question “where did the time go” is answered without arithmetic. The header names the transport and the delivery plane, which is the first thing to check when a run is unexpectedly slow:

squiggle flow login
flow login 19 steps · direct · in-process taps    0 tap                 tap text=Stress via hid                259ms    1 type                enter email  type testID=email-inp…    1.3s     18 assert              counter reset to 0  1 assertion(s)…     1ms   PASS  19 of 19 steps  11.6s  12 scrollUntilVisible  ████████████████████████  1.6s  text=Item 20 visible after 2 scroll(s)  14 scrollUntilVisible  ███████████████████████░  1.6s  text=Log out visible after 2 scroll(s)  1 type                 ████████████████████░░░░  1.3s  enter email  type testID=email-input via hid  19 steps · 10.6s of 11.6s wall clock

The five phases

Every step is timed in the same five buckets, and they are what the bench compares:

settleGate
Waiting for the app to stop moving before the step is allowed to act. Usually the largest bucket, and the one --fast-settle attacks.
resolve
Turning a selector into a node. Graph first, then the accessibility tree — the bucket --speculative-resolve overlaps away.
rectStable
Waiting for the target’s rectangle to stop moving, so a tap cannot land on where a control used to be.
deliver
The actuation itself — the in-process round trip, or the HID events.
stepDelay
The deliberate pause after a step. CPU-side, so it is flat under machine contention while the other four move.

The artifact

Every run writes NDJSON to .squiggle/runs/<runId>.ndjson — one line per step with its selector, status, heal, duration and the five phases above. A failed run leaves a queryable trail instead of requiring a re-run, and --events streams the same lines to stderr live, so CI gets the stream while a human gets the spinner.

Catching a regression

squiggle bench runs a flow through the ordinary runner — so it measures what a real run costs — and compares against the last benched run with the same flags and transport. Comparing a --fast-settle run against a non-fast-settle one would be noise dressed as data, so it refuses to.

squiggle bench login
  10.5s  -57ms (-1%)  vs 10.5s on 2026-07-30 13:41 (74193f7+dirty)  flags inproc · transport direct  settleGate  ██████████████████████████████   3.1s  +36ms (+1%)  deliver     █████████████░░░░░░░░░░░░░░░░░   1.4s  +50ms (+4%)  stepDelay   ██████████░░░░░░░░░░░░░░░░░░░░   1.1s  ±0ms (0%)  rectStable  ███████░░░░░░░░░░░░░░░░░░░░░░░  685ms  +23ms (+3%)  resolve     █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   76ms  -7ms (-8%)  phase totals · vs the last same-flags run  dropped frames  67 (-20)  peak RSS        512.5 MB (-99.5 MB)  GC              55 (-19) runs / 132 µs (+19 µs)

The phase bars share one scale, so a regression names its own cause. The telemetry block underneath is pulled from the run’s session in perf.db — dropped frames from the dylib’s frame truth, peak RSS from host process sampling, GC from Hermes. A value with no producer reads null, never zero.

The trend, not just the last two

squiggle bench login --history prints the recent runs as a table, so a slow creep is visible as a slope rather than as a series of one-percent deltas that each looked like noise. The raw history is JSONL under .squiggle/bench/, keyed by flow and flag set.

Running a suite

Sharding across simulators

squiggle flows partitions a directory across N booted simulators and runs them as parallel children, one port and one udid per shard — so wall-clock scales with devices rather than with tests.

Terminal
squiggle flows ./flows --shards 4squiggle flows ./flows --shards 4 --clone-from <UDID>   # clone a sim per shard

The authoring loop

And the loop that makes authoring fast: squiggle watch <flow> re-runs on every file save against the warm session. A save during a run marks the loop dirty and re-runs at the end rather than interrupting — 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.

Next: Record & replay, which makes a run reproducible without the device at all.