Shotlist guide
Readiness and fixtures
How Shotlist decides a scene is ready to capture, how to hold it for async work with hold and waitUntil, timeouts, and answering HTTP requests from fixtures, including AsyncImage, Kingfisher, Nuke and Firebase.
Readiness and fixtures
Shotlist never waits a fixed time before capturing. It captures a scene when ShotlistKit, inside your app, says the scene is ready, and then only once the screen has stopped changing. This page explains what “ready” means, how to tell ShotlistKit about work it can’t see, and how your app’s network requests are answered from fixtures.
When is a scene ready?
Four steps, in order:
- The scene’s
preparehas finished. - The root has appeared, no request is in flight through ShotlistKit, and every hold is released. For a link scene, the URL has been handed to the app.
- Two further frames have rendered with nothing new happening. If a request or hold started in the meantime, ShotlistKit waits for it and checks again. It then hides scroll indicators, which flash when a scroll view appears, and waits one more frame before reporting ready.
- The engine captures until two consecutive frames are identical. This catches system chrome that settles after the app is ready. A difference of at most 2 levels in at most 1% of the pixels counts as identical, because the simulator renders Liquid Glass slightly differently from frame to frame (see Making runs identical). For the first capture after a launch, the identical pair must start at least 2.5 s after the launch: iOS fades out the home indicator about 2 s after an app opens, with nothing to signal it.
Steps 1 to 3 happen in your app; step 4 happens in the engine. If a request that ShotlistKit answered had no fixture, the scene fails after step 3, naming the URL.
Before the scene appears, ShotlistKit dismisses the keyboard. Animations are off for the whole capture, and the text caret is hidden.
What ShotlistKit sees by itself
- Requests through
URLSession.shared. ShotlistKit registers its URL protocol at launch, so these need no change. - Requests through sessions you create, once you add
Shotlist.protocolClassesto their configuration (below). AsyncImageunderShotlistHost, which routes it through the fixtures.
Each of these counts as “in flight” from the moment it starts until its response is delivered.
Everything else is invisible to readiness: a database query, an image decode, a Task that sleeps before loading, a stream from a local store, a third-party SDK with its own networking. For those, use a hold or waitUntil.
Holds
A hold keeps the current scene from being ready until it’s released. Give each one a label: if the scene times out, the label is how the error names it.
Shotlist.hold
Wraps async work and returns its result:
let photo = await Shotlist.hold("profile photo") { try await loader.load() }
hold rethrows, so a throwing closure needs try. The hold is released when the closure returns or throws.
Shotlist.beginHold
The non-async form, for callback APIs. It returns a ShotlistHold. Call release() when the work is done:
let hold = Shotlist.beginHold("map tiles")
mapView.loadTiles { _ in
hold.release()
}
A ShotlistHold also releases itself when it’s deallocated, so a hold you lose track of can’t block the scene for ever. Keep a reference until the work finishes.
waitUntil
Holds the scene until a condition is true, re-checking whenever the @Observable state it reads changes:
await Shotlist.waitUntil("league loaded") { model.league?.games.isEmpty == false }
It’s for loading ShotlistKit can’t see, such as a local store’s async stream, or a TCA store whose effect loads from disk. It runs on the main actor.
The condition is re-checked only when observable state it read changes. If it reads plain properties, a non-observable object or a global, it’s checked once and then waits until the scene times out. Make sure what it reads is @Observable (or TCA’s @ObservableState).
Call holds and waitUntil from where the work starts: a scene’s prepare, a view’s .task, or the model method that loads. Readiness only needs the hold to begin before step 3 finishes.
Shotlist.hold, beginHold and waitUntil are inside #if DEBUG like the rest of ShotlistKit, so wrap calls in your app code the same way. Outside a capture they still run: hold simply runs its work, and waitUntil still waits for its condition. Guard them with Shotlist.isActive where that matters:
.task {
#if DEBUG
if Shotlist.isActive {
await Shotlist.waitUntil("stock levels") { model.stock != nil }
}
#endif
}
Work that starts late
Step 3 waits two frames after the last request or hold ends. Work that starts later than that is missed, and the scene is captured before it. Typical causes:
- a
Task.sleepor debounce before a load; - a
TimerorDispatchQueue.asyncAfter; - a chain of loads where the next starts a while after the previous one ends.
Wrap the whole chain in one hold, or waitUntil the final state.
Timeouts
| Wait | Limit | What you see if it runs out |
|---|---|---|
| The app’s hello after launch | 90 s | timed out after 90 s waiting for hello from the app (pid …) on the control channel |
| A scene becoming ready, in the app | capture.sceneTimeout, 20 s by default |
not ready after 20 s: open hold 'profile photo' or pending GET https://… |
| The same, as seen by the engine | sceneTimeout + 15 s |
timed out after 35 s waiting for ready from scene '…' |
| The screen settling (step 4) | 13 captures | the screen kept changing across 13 captures; something on it is animating (a spinner, a looping animation or a ticking time) |
The in-app timeout covers prepare, building the scene and steps 2 and 3 together. Its message lists everything still open:
open hold 'profile photo', or several:open holds 'a', 'b';pending GET https://api.example.com/v1/orders;- both, separated by a semicolon;
nothing open (the scene never appeared), when nothing was open but the scene didn’t finish: its view never appeared, orpreparewas still running.
To give slow scenes more time, raise capture.sceneTimeout in shotlist.json, in whole seconds:
"capture": { "sceneTimeout": 30 }
A longer timeout only matters for a scene that’s slow, not for one that’s stuck. For a stuck scene, the message names what to fix.
HTTP fixtures
During a capture, every HTTP and HTTPS request ShotlistKit intercepts goes to the engine, which answers it from files in Shotlist/Fixtures/. Your app’s networking code stays as it is. Its real decoding, error handling and formatting run over fixture data.
Layout
Shotlist/Fixtures/<locale>/<METHOD>/<path>
Shotlist/Fixtures/shared/<METHOD>/<path>
For each request, Shotlist looks in the run’s locale folder first, then in shared/. A real layout:
Shotlist/Fixtures/
shared/
GET/
v1/
products.json GET /v1/products
products/
enamel-kettle.json GET /v1/products/enamel-kettle
orders.json
promotions.json
images/
products/
enamel-kettle.png GET /images/products/enamel-kettle.png
de-DE/
GET/
v1/
products.json German names and prices for de-DE
products/
enamel-kettle.json
en-GB/
GET/
v1/
promotions.json
Matching rules
| Rule | Example |
|---|---|
| The method is the folder, in capitals. | POST /v1/orders → shared/POST/v1/orders.json |
| Only the URL’s path is used. The host, port and query string are ignored. | https://img.example.com/images/a.png?w=400 → shared/GET/images/a.png |
| The file’s extension is optional in the URL. | GET /v1/products → shared/GET/v1/products.json |
| A file and a folder may share a name. | products.json answers /v1/products; products/ holds /v1/products/<id>. |
The locale folder wins over shared/. |
de-DE/GET/v1/products.json answers a de-DE run; other locales get shared/. |
| If several files match without an extension, the first by name wins. | items.json before items.xml |
Because hosts are ignored, two APIs that use the same path share one fixture. Give them different path prefixes in your fixtures layout if that’s a problem, or route one of them through a protocol the scene can swap.
Responses
Every matched request gets status 200, with Content-Type from the file’s extension and Content-Length:
| Extension | Content-Type |
|---|---|
json |
application/json |
png |
image/png |
jpg, jpeg |
image/jpeg |
webp |
image/webp |
gif |
image/gif |
svg |
image/svg+xml |
html |
text/html; charset=utf-8 |
txt |
text/plain; charset=utf-8 |
| anything else | application/octet-stream |
The request’s body and headers reach the engine but aren’t used for matching. Fixtures can’t return other status codes or custom headers; to show an error state, build the scene with a model already in that state.
Use opaque PNGs for image fixtures. Dates in JSON should be fixed values relative to fixedDate, so relative dates don’t drift (see Making runs identical).
A request with no fixture
It fails, and so does its scene. The run log and the cell’s error say exactly where Shotlist looked:
scene 'missing-fixture' failed: No fixture for GET /v1/products/no-such-product (https://api.example-shotlist.test/v1/products/no-such-product); looked in Fixtures/en-GB/GET/v1/products/no-such-product and Fixtures/shared/GET/v1/products/no-such-product
Add the file it names, or change the scene so it doesn’t make that request. A good way to find every request a screen makes is to capture it once with an empty Fixtures/ folder and read the failures.
Point your app at a host that can’t resolve in Debug, such as one ending in .test. Then any request that escapes ShotlistKit fails visibly instead of reaching a real server.
Sessions you create
URLSession.shared needs nothing. For a session with its own configuration, add Shotlist’s protocol class to the configuration before creating the session:
init() {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 20
#if DEBUG
// The only Shotlist change in the app's networking: answer from fixtures during a capture.
if Shotlist.isActive {
configuration.protocolClasses = Shotlist.protocolClasses + (configuration.protocolClasses ?? [])
}
#endif
session = URLSession(configuration: configuration)
}
The same works for any library that takes a URLSessionConfiguration.
Kingfisher
Kingfisher’s downloader uses its own ephemeral session. Give it one with Shotlist’s protocol, early in launch:
#if DEBUG
if Shotlist.isActive {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = Shotlist.protocolClasses + (configuration.protocolClasses ?? [])
KingfisherManager.shared.downloader.sessionConfiguration = configuration
}
#endif
Nuke
Nuke’s DataLoader uses its own session. Replace the shared pipeline’s loader:
#if DEBUG
if Shotlist.isActive {
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = Shotlist.protocolClasses + (configuration.protocolClasses ?? [])
ImagePipeline.shared = ImagePipeline { $0.dataLoader = DataLoader(configuration: configuration) }
}
#endif
AsyncImage
On iOS 27, AsyncImage loads through SwiftUI’s own loader rather than URLSession.shared. ShotlistHost routes it through the fixtures for every scene it shows, so it needs no change. A SwiftUI view inside your own UIHostingController, within a UIKit scene, doesn’t get this; give it .asyncImageURLSession(_:) with a session configured as above if it uses AsyncImage.
What isn’t intercepted
WKWebView, which loads in its own process.- Web sockets, and protocols other than HTTP and HTTPS.
- SDKs with their own networking stack that don’t use
URLSession(gRPC, for example).
Put these behind a protocol the scene can swap for a fixture implementation, or keep them off the screens you capture.
Firebase
Point the app at the Firebase Emulator Suite during a capture, seeded with fixture data, instead of fixtures:
#if DEBUG
if Shotlist.isActive {
Firestore.firestore().useEmulator(withHost: "localhost", port: 8080)
Auth.auth().useEmulator(withHost: "localhost", port: 9099)
}
#endif
The simulator reaches the emulators on localhost. Firestore doesn’t load through URLSession, so readiness can’t see its queries: waitUntil the state your view shows, or wrap the first load in a hold.
A large SDK such as Firebase makes the first build slow. If you already have a DerivedData folder with it compiled, point app.derivedData in shotlist.json at it. See Configuration.