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 —
UITouchdelivery, caret insert for typing, directcontentOffsetfor 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
--headlessselects, 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/settledover 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-sidefindpre-pass, so an off-screen target is a fast definitive miss rather than a sweep. - Direct scroll: 2.3 s → 17 ms
- A gesture
scrollUntilVisibleis a loop of drag, re-measure, drag again.mode: directasks the dylib to drive the enclosing scroll view’scontentOffsetserver-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
focusTypeverb 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’ssettledprobe 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-resolvepre-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
settledprobe. Needs an injected app; without one the sleeps stay fixed. --direct-scroll- Make
mode: directthe default for everyscrollUntilVisible. An explicitmode: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
networkIdleandnetwork.stubreal.
Combining them
The fast configuration for a suite that is testing behaviour rather than scroll physics:
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 settleIn-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:
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 clockThe 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-settleattacks. - resolve
- Turning a selector into a node. Graph first, then the accessibility tree — the bucket
--speculative-resolveoverlaps 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.
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.
squiggle flows ./flows --shards 4squiggle flows ./flows --shards 4 --clone-from <UDID> # clone a sim per shardThe 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.