Skip to content
Launch offer: Mac Manager £9.99 first year

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:

  1. The scene’s prepare has finished.
  2. 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.
  3. 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.
  4. 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.protocolClasses to their configuration (below).
  • AsyncImage under ShotlistHost, 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.sleep or debounce before a load;
  • a Timer or DispatchQueue.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, or prepare was 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.