Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions Shared/SnapshotKitTesting/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first.
timeout by widening the budget — freeze the motion behind
`\.isCapturingSnapshot`, or use `.settledAtLeast` only when the content is
genuinely slow rather than endless.
- **The settle budget bounds observed motion, not proof-of-stability.** On a
starved machine a single settle pass can cost over a second, so fewer passes
than stability needs may fit in the budget; failing then blames "content
still changing" on content never once seen to change (CI reproduced exactly
that: a static screen timing out ~50% of cold runs while its capture still
matched the reference). So `.timedOut` requires an *observed* change, a
change-free loop keeps running until it can prove stability, and only a hard
cap several budgets out gives up as `.starved` — an environment failure, not
view motion. Guarded by `SnapshotRenderingSupportTests`.
- **Captures are single-tenant per process, enforced by `SnapshotCaptureLock`.**
Every capture holds process-global state (the safe-area swizzle + override
globals, the animations flag, the one `StuffTestHost` key window) across the
Expand Down Expand Up @@ -102,10 +111,11 @@ Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first.

`SnapshotKitTestingTests` (`Tests/`, wired in `Project.swift`, in the
`Stuff-iOS-Tests` scheme) owns the pipeline's own regression tests: async-content
settle, concurrent-capture serialization, duplicate-identifier detection,
tile-and-stitch / full-content sizing, the pre-capture hook, the same-image
capture-flag surface, and safe-area composition (the swizzle zeroes the captured
root while an interior `safeAreaInset` still composes). They render through
settle, the settle loop's ending conditions (starved-but-static settles, observed
motion times out), concurrent-capture serialization, duplicate-identifier
detection, tile-and-stitch / full-content sizing, the pre-capture hook, the
same-image capture-flag surface, and safe-area composition (the swizzle zeroes
the captured root while an interior `safeAreaInset` still composes). They render through
`renderSnapshotImage` (so they need the `StuffTestHost` key window) but assert on
probed pixels via the `@_spi(Testing)` `PixelSample`/`probePixel` API rather than
LFS reference images — so the bundle is fast, has no `__Snapshots__/`, and runs
Expand Down
12 changes: 8 additions & 4 deletions Shared/SnapshotKitTesting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,14 @@ re-exports `SnapshotKit` and `SnapshotTesting`, so a test author needs a single
optional `onReadyToSnapshot` hook runs after that settle and before the
accessibility parse / capture — the deterministic point to focus a field or
trigger a presented state — and its effects are settled again before the
image is taken. Content that never stops moving within the budget **fails the
test** rather than capturing an arbitrary frame: the failure names the phase
and points at freezing the motion behind `\.isCapturingSnapshot` or raising
the floor with `.settledAtLeast`.
image is taken. Content **observed** still moving at the budget **fails the
test** rather than capturing an arbitrary frame: the failure names the
capture and the phase, and points at freezing the motion behind
`\.isCapturingSnapshot` or raising the floor with `.settledAtLeast`. The
budget bounds observed motion only — a loop that never saw the content
change but couldn't complete enough render passes to prove stability (a
starved CI machine) keeps waiting instead of failing falsely, giving up at a
hard cap several budgets out.
- **Accessibility captures** — for `.accessibility` configurations, content is
wrapped so the image is annotated with the VoiceOver reading order, labels,
traits, and activation points.
Expand Down
3 changes: 2 additions & 1 deletion Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import UIKit
///
/// `async` because the render pipeline must suspend for SwiftUI `.task`-driven
/// content to load before capture — see
/// ``renderSnapshotImage(of:sizing:safeAreaInsets:isAccessibility:)``.
/// ``renderSnapshotImage(of:named:sizing:safeAreaInsets:isAccessibility:settle:onReadyToSnapshot:)``.
@MainActor
public func assertSnapshots(
of provider: (some SnapshotProviding).Type,
Expand Down Expand Up @@ -115,6 +115,7 @@ public func assertSnapshots(
let identifier = fullSnapshotIdentifier(caseName: name, configuration: configuration)
let image = await renderSnapshotImage(
of: hostingController,
named: identifier,
sizing: sizing,
safeAreaInsets: configuration.device.safeAreaInsets.uiEdgeInsets,
isAccessibility: configuration.snapshotType == .accessibility,
Expand Down
15 changes: 14 additions & 1 deletion Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,15 @@ public enum SnapshotSizing: Sendable {
/// in-memory-vs-on-disk deltaE flake).
///
/// Requires the StuffTestHost key window; call only from a hosted test bundle.
///
/// `named` labels the capture in settle-phase failures — `assertSnapshots`
/// passes the full snapshot identifier, so a timeout on one configuration of a
/// 16-image matrix names which one (the gap that made CI's About-screen settle
/// failures undiagnosable from the result bundle alone).
@MainActor
public func renderSnapshotImage(
of viewController: UIViewController,
named name: String,
sizing: SnapshotSizing = .fixed,
safeAreaInsets: UIEdgeInsets? = .zero,
isAccessibility: Bool = false,
Expand All @@ -74,6 +80,7 @@ public func renderSnapshotImage(
await SnapshotCaptureLock.withLock {
await renderSnapshotImageLocked(
of: viewController,
named: name,
sizing: sizing,
safeAreaInsets: safeAreaInsets,
isAccessibility: isAccessibility,
Expand All @@ -84,11 +91,12 @@ public func renderSnapshotImage(
}

/// The capture body of
/// ``renderSnapshotImage(of:sizing:safeAreaInsets:isAccessibility:settle:onReadyToSnapshot:)``,
/// ``renderSnapshotImage(of:named:sizing:safeAreaInsets:isAccessibility:settle:onReadyToSnapshot:)``,
/// run while holding ``SnapshotCaptureLock``.
@MainActor
private func renderSnapshotImageLocked(
of viewController: UIViewController,
named name: String,
sizing: SnapshotSizing,
safeAreaInsets: UIEdgeInsets?,
isAccessibility: Bool,
Expand Down Expand Up @@ -123,6 +131,7 @@ private func renderSnapshotImageLocked(

await resolveContentSize(
of: viewController,
named: name,
sizing: sizing,
settle: settle,
hostedIn: hostRoot,
Expand All @@ -148,6 +157,7 @@ private func renderSnapshotImageLocked(
settleForCapture(wrappingViewController.view, settle: settle),
phase: "content",
of: viewController,
named: name,
)

// The pre-capture hook sees fully settled content, then its own
Expand All @@ -161,6 +171,7 @@ private func renderSnapshotImageLocked(
settleForCapture(wrappingViewController.view, settle: settle),
phase: "onReadyToSnapshot",
of: viewController,
named: name,
)
}

Expand Down Expand Up @@ -242,6 +253,7 @@ private func removeChildAfterCapture(_ child: UIViewController) {
@MainActor
private func resolveContentSize(
of viewController: UIViewController,
named name: String,
sizing: SnapshotSizing,
settle: SnapshotSettle,
hostedIn hostRoot: UIViewController,
Expand All @@ -260,6 +272,7 @@ private func resolveContentSize(
settleForCapture(probeWrapper.view, settle: settle),
phase: "intrinsic measurement",
of: viewController,
named: name,
)

func measureContent() -> CGSize {
Expand Down
107 changes: 81 additions & 26 deletions Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,21 @@ import UIKit
/// the capture is about to record are the ones the case declared — the rest
/// describe a capture the pipeline can't vouch for, so callers report them
/// instead of quietly proceeding.
enum SettleOutcome: Equatable {
@_spi(Testing) public enum SettleOutcome: Equatable {
/// Renders reached pixel stability and held it through the quiet window.
case settled
/// The case declared `.immediate`, so no settle loop ran.
case skipped
/// The budget elapsed with the content still changing. Whatever gets
/// captured is an arbitrary frame of whatever is still in motion, which is
/// how a flaky reference gets recorded.
/// The content was *observed* changing and was still changing when the
/// budget elapsed. Whatever gets captured is an arbitrary frame of
/// whatever is still in motion, which is how a flaky reference gets
/// recorded.
case timedOut(budget: TimeInterval)
/// The loop never observed the content change, but render passes were too
/// slow to *prove* stability (three matching samples) before the extended
/// cap. A starved machine, not a moving view — or a view that renders no
/// pixels at all (a zero-sized frame).
case starved(passes: Int, cap: TimeInterval)
/// The task was cancelled mid-settle.
case cancelled
}
Expand All @@ -30,19 +36,31 @@ func reportIfUnsettled(
_ outcome: SettleOutcome,
phase: String,
of viewController: UIViewController,
named name: String,
) {
switch outcome {
case .settled, .skipped:
return
case let .timedOut(budget):
Issue.record(
"""
Snapshot content never settled: the \(phase) phase for \
\(type(of: viewController)) was still changing after \(budget.formatted())s, \
so this capture is an arbitrary frame of whatever is still moving. Freeze the \
motion at a deterministic phase behind `\\.isCapturingSnapshot` (the Where app \
does this with `MotionIsStatic`), or — if the content is merely slow rather than \
endless — raise the floor with `.settledAtLeast(minDuration:)`.
Snapshot content never settled: the \(phase) phase for "\(name)" \
(\(type(of: viewController))) was observed still changing \(budget.formatted())s \
after hosting, so this capture is an arbitrary frame of whatever is still moving. \
Freeze the motion at a deterministic phase behind `\\.isCapturingSnapshot` (the \
Where app does this with `MotionIsStatic`), or — if the content is merely slow \
rather than endless — raise the floor with `.settledAtLeast(minDuration:)`.
""",
)
case let .starved(passes, cap):
Issue.record(
"""
Snapshot settle starved: the \(phase) phase for "\(name)" \
(\(type(of: viewController))) completed only \(passes) render pass(es) in \
\(cap.formatted())s without ever observing the content change, so pixel \
stability could not be confirmed. This is an environment problem (a machine too \
loaded to complete render passes) or a view that renders no pixels (a zero-sized \
frame) — not view motion; widening the settle budget won't fix it.
""",
)
case .cancelled:
Expand Down Expand Up @@ -114,33 +132,53 @@ func settleForCapture(_ view: UIView, settle: SnapshotSettle) async -> SettleOut
/// material crossfade) can quantize to zero between *adjacent* frames while
/// still drifting across the window — the anchor comparison catches the drift.
///
/// Returns how it ended: exhausting `maxDuration` means the content never
/// stopped moving, which the caller reports rather than capturing an arbitrary
/// frame and calling it a reference.
/// `maxDuration` budgets **observed motion**, not proof-of-stability. On a
/// starved machine a single pass (sleep + layout + render) can cost over a
/// second, so fewer than the three passes stability needs may fit in the
/// budget — and failing then would blame "content still changing" on content
/// that was never once seen to change (CI reproduced exactly that: the About
/// screen, static from its first frame, timed out ~50% of runs on a cold
/// loaded runner while its capture still matched the reference). So the
/// deadline only produces ``SettleOutcome/timedOut(budget:)`` when a change
/// was *observed* past anchor establishment; a change-free loop keeps running
/// until it can prove stability, giving up as
/// ``SettleOutcome/starved(passes:cap:)`` only at a hard cap several multiples
/// of the budget. Moving content can't slip through: any observed change past
/// the budget still fails, exactly as before.
@MainActor
func settleContent(
@_spi(Testing) public func settleContent(
_ view: UIView,
minDuration: TimeInterval = 0.25,
maxDuration: TimeInterval = 2.5,
) async -> SettleOutcome {
// How long the rendered pixels must stay byte-identical to the quiet
// window's anchor sample before the content counts as settled — matches the
// old 2-passes-at-60ms quiet window, now sampled densely.
let stableQuietDuration: TimeInterval = 0.12
let stableQuietDuration: Duration = .milliseconds(120)
// How many multiples of the budget a change-free loop may spend proving
// stability before giving up as starved. Generous on purpose: the cap only
// gates runs that would otherwise fail falsely, and a genuinely moving view
// is still bounded by `maxDuration` the moment a change is observed.
let starvationHeadroom: Double = 4

let start = Date()
let minDeadline = start.addingTimeInterval(minDuration)
let maxDeadline = start.addingTimeInterval(maxDuration)
let clock = ContinuousClock()
let start = clock.now
let minDeadline = start.advanced(by: .seconds(minDuration))
let maxDeadline = start.advanced(by: .seconds(maxDuration))
let starvationDeadline = start.advanced(by: .seconds(maxDuration * starvationHeadroom))
var anchorSample: Data?
var anchorDate = Date()
var anchorTime = start
var stablePasses = 0
while Date() < maxDeadline {
var passCount = 0
var observedContentChange = false
while true {
do {
try await Task.sleep(for: .milliseconds(16))
} catch {
return .cancelled
}
CATransaction.performWithoutAnimation(view.layoutIfNeeded)
passCount += 1
let sample = view.renderedContentSample()
// Byte-exact on purpose — not the tolerance the final image compare uses.
// The final compare answers "does this match the reference?", where
Expand All @@ -152,18 +190,34 @@ func settleContent(
if sample != nil, sample == anchorSample {
stablePasses += 1
} else {
// The first non-nil sample merely establishes the anchor; only a
// departure from an established anchor is the content changing.
if anchorSample != nil {
observedContentChange = true
}
anchorSample = sample
anchorDate = Date()
anchorTime = clock.now
stablePasses = 0
}
let now = clock.now
if stablePasses >= 2,
Date() >= anchorDate.addingTimeInterval(stableQuietDuration),
Date() >= minDeadline
now >= anchorTime.advanced(by: stableQuietDuration),
now >= minDeadline
{
return .settled
}
// Checked after the stability check on purpose: a pass that completes
// its proof late still settles rather than timing out — proven-stable
// content is safe to capture no matter when the proof landed.
if now >= maxDeadline {
if observedContentChange {
return .timedOut(budget: maxDuration)
}
if now >= starvationDeadline {
return .starved(passes: passCount, cap: maxDuration * starvationHeadroom)
}
}
}
return .timedOut(budget: maxDuration)
}

extension UIView {
Expand Down Expand Up @@ -205,9 +259,10 @@ func drainInFlightAnimations(timeout: TimeInterval = 1) -> Bool {
var completed = false
UIView.animate(withDuration: 0) {} completion: { _ in completed = true }

let deadline = Date(timeIntervalSinceNow: timeout)
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(timeout))
while !completed {
if Date() > deadline { return false }
if clock.now > deadline { return false }
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.001))
}
return true
Expand Down
3 changes: 2 additions & 1 deletion Shared/SnapshotKitTesting/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
## P2s (Nice to have)
- fix: Cancellation mid-settle proceeds to capture and assert — `settleContent` now reports `.cancelled` (`Sources/SnapshotRenderingSupport.swift`) but `reportIfUnsettled` deliberately stays quiet on it, so a cancelled test (e.g. a future time-limit trait) still captures half-settled content and records a spurious image mismatch on top of the cancellation. Propagate the outcome out of `renderSnapshotImage` so `assertSnapshots` can skip the comparison entirely, keeping cancelled tests clean. (From the July 2026 snapshot-testing PR review.)
- refactor: Drop the unused `AccessibilitySnapshot` umbrella product dependency (root `Package.swift`, SnapshotKitTesting target) — only `AccessibilitySnapshotCore` is imported; the umbrella product just widens the statically-embedded closure of the consuming test bundle. (From the July 2026 snapshot-testing PR review.)
- refactor: Replace wall-clock `Date()` deadlines in `settleContent` and `drainInFlightAnimations` (`Sources/SnapshotRenderingSupport.swift`) with `ContinuousClock` — the modern, suspension-proof way to measure elapsed time per the concurrency skill. Behavior-neutral cleanup. (From the July 2026 snapshot-testing PR review.)
- fix: The duplicate-identifier guard only protects the provider overload of `assertSnapshots` (`Sources/AssertSnapshots.swift`) — the inline `assertSnapshots(of:named:configurations:)` overload accepts a `configurations` array containing duplicates and silently compares the second against the first's recording. Run the same guard over `[SnapshotCase(name:configurations:)]` there. (From the July 2026 snapshot-testing PR review.)

# Completed issues
- fix: The settle timeout fired on starved-but-static content — under CPU starvation (a cold, loaded CI runner) a single settle pass (16ms sleep + layout + quarter-res render) cost over a second, so the three passes stability needs didn't fit the 2.5s budget and `settleContent` failed static content as "never settled" (~50% of cold CI runs on the About screen, whose capture still matched the reference; reproduced locally by duty-cycling SIGSTOP/SIGCONT on `StuffTestHost`). (Resolved: the budget now bounds *observed motion* — `.timedOut` requires a change seen past anchor establishment, a change-free loop keeps running until it proves stability, and a hard cap at 4× the budget gives up as the new `.starved` outcome naming the pass count. Settle failures also now name the full snapshot identifier via `renderSnapshotImage(of:named:...)`, so a matrix timeout says which configuration. Guarded by `SnapshotRenderingSupportTests`.)
- refactor: Replace wall-clock `Date()` deadlines in `settleContent` and `drainInFlightAnimations` (`Sources/SnapshotRenderingSupport.swift`) with `ContinuousClock` — the modern, suspension-proof way to measure elapsed time per the concurrency skill. Behavior-neutral cleanup. (From the July 2026 snapshot-testing PR review.) (Resolved: both now measure with `ContinuousClock`, landed with the starved-settle fix above.)
- fix: The settle loop timed out silently — when content never reached pixel stability, `settleContent`'s loop just exited and the capture proceeded with a mid-animation frame, with nothing distinguishing "settled" from "gave up after 2.5s". (Resolved: `settleContent`/`settleForCapture` return a `SettleOutcome`, and `reportIfUnsettled` records an `Issue` naming the phase, the view controller, and the budget when the content never stopped moving. All 232 WhereUI references capture without a single timeout, so the failure path costs nothing today and catches the next un-frozen animation.)
Loading
Loading