From 53d4f2543e436c6d97bc27e74f7ca3c61d1ef8cb Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 26 Jul 2026 23:29:22 -0700 Subject: [PATCH] Only fail the settle loop on observed motion, not starvation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under CPU starvation (a cold, loaded CI runner) a single settle pass (16ms sleep + layout + quarter-res render) can cost over a second, so the three passes stability needs don't fit the 2.5s budget and settleContent failed static content as "Snapshot content never settled" — ~50% of cold CI runs on the About screen, whose capture still matched its reference. Reproduced locally by duty-cycling SIGSTOP/SIGCONT on StuffTestHost (1.4s stopped / 0.12s running). The budget now bounds *observed motion*: .timedOut requires a change seen past anchor establishment, a change-free loop keeps running until it can prove stability, and a hard cap at 4x the budget gives up as the new .starved outcome naming the pass count. Deadlines measure with ContinuousClock (closing that TODO), and settle failures now name the full snapshot identifier via renderSnapshotImage(of:named:...), so a timeout in a 16-image matrix says which configuration. Verified: the same duty-cycle starvation that produced four settle timeouts now runs the About suite green (103s, every capture extended past its budget and still matched the reference), and the full StuffSnapshotTests scheme passes unstarved. New SnapshotRenderingSupportTests pin all three ending conditions. Co-authored-by: Cursor --- Shared/SnapshotKitTesting/AGENTS.md | 18 ++- Shared/SnapshotKitTesting/README.md | 12 +- .../Sources/AssertSnapshots.swift | 3 +- .../Sources/SnapshotImageRendering.swift | 15 ++- .../Sources/SnapshotRenderingSupport.swift | 107 +++++++++++++----- Shared/SnapshotKitTesting/TODOs.md | 3 +- .../Tests/AsyncContentCaptureTests.swift | 6 +- .../Tests/ConcurrentCaptureTests.swift | 1 + .../Tests/LargeViewCaptureTests.swift | 7 +- .../Tests/PreCaptureHookTests.swift | 1 + .../Tests/SafeAreaCompositionTests.swift | 12 +- .../Tests/SnapshotCaptureFlagTests.swift | 6 +- .../Tests/SnapshotRenderingSupportTests.swift | 78 +++++++++++++ .../SnapshotCaptureFlagProbeTests.swift | 6 +- 14 files changed, 232 insertions(+), 43 deletions(-) create mode 100644 Shared/SnapshotKitTesting/Tests/SnapshotRenderingSupportTests.swift diff --git a/Shared/SnapshotKitTesting/AGENTS.md b/Shared/SnapshotKitTesting/AGENTS.md index 9e82fbe1..bbc0c809 100644 --- a/Shared/SnapshotKitTesting/AGENTS.md +++ b/Shared/SnapshotKitTesting/AGENTS.md @@ -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 @@ -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 diff --git a/Shared/SnapshotKitTesting/README.md b/Shared/SnapshotKitTesting/README.md index 729ebbd3..07188e65 100644 --- a/Shared/SnapshotKitTesting/README.md +++ b/Shared/SnapshotKitTesting/README.md @@ -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. diff --git a/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift b/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift index cce3b069..01af6aae 100644 --- a/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift +++ b/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift @@ -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, @@ -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, diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift b/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift index 0f8097df..5ef98ebc 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift @@ -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, @@ -74,6 +80,7 @@ public func renderSnapshotImage( await SnapshotCaptureLock.withLock { await renderSnapshotImageLocked( of: viewController, + named: name, sizing: sizing, safeAreaInsets: safeAreaInsets, isAccessibility: isAccessibility, @@ -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, @@ -123,6 +131,7 @@ private func renderSnapshotImageLocked( await resolveContentSize( of: viewController, + named: name, sizing: sizing, settle: settle, hostedIn: hostRoot, @@ -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 @@ -161,6 +171,7 @@ private func renderSnapshotImageLocked( settleForCapture(wrappingViewController.view, settle: settle), phase: "onReadyToSnapshot", of: viewController, + named: name, ) } @@ -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, @@ -260,6 +272,7 @@ private func resolveContentSize( settleForCapture(probeWrapper.view, settle: settle), phase: "intrinsic measurement", of: viewController, + named: name, ) func measureContent() -> CGSize { diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift b/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift index ac1a783a..959736ed 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift @@ -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 } @@ -30,6 +36,7 @@ func reportIfUnsettled( _ outcome: SettleOutcome, phase: String, of viewController: UIViewController, + named name: String, ) { switch outcome { case .settled, .skipped: @@ -37,12 +44,23 @@ func reportIfUnsettled( 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: @@ -114,11 +132,21 @@ 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, @@ -126,21 +154,31 @@ func settleContent( // 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 @@ -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 { @@ -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 diff --git a/Shared/SnapshotKitTesting/TODOs.md b/Shared/SnapshotKitTesting/TODOs.md index aabdad40..14e78aca 100644 --- a/Shared/SnapshotKitTesting/TODOs.md +++ b/Shared/SnapshotKitTesting/TODOs.md @@ -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.) diff --git a/Shared/SnapshotKitTesting/Tests/AsyncContentCaptureTests.swift b/Shared/SnapshotKitTesting/Tests/AsyncContentCaptureTests.swift index 876870e1..e89f095a 100644 --- a/Shared/SnapshotKitTesting/Tests/AsyncContentCaptureTests.swift +++ b/Shared/SnapshotKitTesting/Tests/AsyncContentCaptureTests.swift @@ -17,7 +17,11 @@ struct AsyncContentCaptureTests { try waitFor { hostKeyWindow() != nil } let host = UIHostingController(rootView: TaskProbeView()) host.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - let image = await renderSnapshotImage(of: host, safeAreaInsets: .zero) + let image = await renderSnapshotImage( + of: host, + named: "task-probe", + safeAreaInsets: .zero, + ) let center = image.probePixel(atUnitPoint: CGPoint(x: 0.5, y: 0.5)) #expect(center.green > 0.5) #expect(center.red < 0.5) diff --git a/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift b/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift index f69f7c64..7d35cbde 100644 --- a/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift +++ b/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift @@ -93,6 +93,7 @@ private func captureProbeImage(topInset: CGFloat) async -> UIImage { host.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) return await renderSnapshotImage( of: host, + named: "safe-area-\(Int(topInset))pt-probe", safeAreaInsets: UIEdgeInsets(top: topInset, left: 0, bottom: 0, right: 0), ) } diff --git a/Shared/SnapshotKitTesting/Tests/LargeViewCaptureTests.swift b/Shared/SnapshotKitTesting/Tests/LargeViewCaptureTests.swift index a7263712..73965a94 100644 --- a/Shared/SnapshotKitTesting/Tests/LargeViewCaptureTests.swift +++ b/Shared/SnapshotKitTesting/Tests/LargeViewCaptureTests.swift @@ -45,6 +45,7 @@ struct LargeViewCaptureTests { host.view.frame = CGRect(x: 0, y: 0, width: 402, height: 1) let image = await renderSnapshotImage( of: host, + named: "full-content-scroll-probe", sizing: .intrinsic(width: 402), safeAreaInsets: .zero, ) @@ -68,7 +69,11 @@ struct LargeViewCaptureTests { .frame(width: 402, height: height) let host = UIHostingController(rootView: view) host.view.frame = CGRect(x: 0, y: 0, width: 402, height: height) - let image = await renderSnapshotImage(of: host, safeAreaInsets: .zero) + let image = await renderSnapshotImage( + of: host, + named: "two-tone-\(Int(height))pt-probe", + safeAreaInsets: .zero, + ) return TwoToneSample( top: image.probePixel(atUnitPoint: CGPoint(x: 0.5, y: 0.1)), bottom: image.probePixel(atUnitPoint: CGPoint(x: 0.5, y: 0.9)), diff --git a/Shared/SnapshotKitTesting/Tests/PreCaptureHookTests.swift b/Shared/SnapshotKitTesting/Tests/PreCaptureHookTests.swift index 73bf0d51..06a4f09f 100644 --- a/Shared/SnapshotKitTesting/Tests/PreCaptureHookTests.swift +++ b/Shared/SnapshotKitTesting/Tests/PreCaptureHookTests.swift @@ -23,6 +23,7 @@ struct PreCaptureHookTests { var hookSawSettledContent = false let image = await renderSnapshotImage( of: host, + named: "pre-capture-hook-probe", safeAreaInsets: .zero, onReadyToSnapshot: { hookSawSettledContent = model.taskFired diff --git a/Shared/SnapshotKitTesting/Tests/SafeAreaCompositionTests.swift b/Shared/SnapshotKitTesting/Tests/SafeAreaCompositionTests.swift index b956b341..dc74f634 100644 --- a/Shared/SnapshotKitTesting/Tests/SafeAreaCompositionTests.swift +++ b/Shared/SnapshotKitTesting/Tests/SafeAreaCompositionTests.swift @@ -21,7 +21,11 @@ struct SafeAreaCompositionTests { try waitFor { hostKeyWindow() != nil } let host = UIHostingController(rootView: Color.red) host.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - let image = await renderSnapshotImage(of: host, safeAreaInsets: .zero) + let image = await renderSnapshotImage( + of: host, + named: "zeroed-root-probe", + safeAreaInsets: .zero, + ) // A safe-area-respecting fill reaches the very top: the device's real // top inset was zeroed at the root rather than leaking into the image. @@ -34,7 +38,11 @@ struct SafeAreaCompositionTests { try waitFor { hostKeyWindow() != nil } let host = UIHostingController(rootView: BarProbeView()) host.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - let image = await renderSnapshotImage(of: host, safeAreaInsets: .zero) + let image = await renderSnapshotImage( + of: host, + named: "safe-area-bar-probe", + safeAreaInsets: .zero, + ) // The 30pt bar sits flush at the top (not offset by device insets) — // the interior contribution composed on the zeroed base. diff --git a/Shared/SnapshotKitTesting/Tests/SnapshotCaptureFlagTests.swift b/Shared/SnapshotKitTesting/Tests/SnapshotCaptureFlagTests.swift index 0ae7c6ce..39effeea 100644 --- a/Shared/SnapshotKitTesting/Tests/SnapshotCaptureFlagTests.swift +++ b/Shared/SnapshotKitTesting/Tests/SnapshotCaptureFlagTests.swift @@ -21,7 +21,11 @@ struct SnapshotCaptureFlagTests { try waitFor { hostKeyWindow() != nil } let host = UIHostingController(rootView: CaptureFlagProbeView()) host.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - let image = await renderSnapshotImage(of: host, safeAreaInsets: .zero) + let image = await renderSnapshotImage( + of: host, + named: "capture-flag-probe", + safeAreaInsets: .zero, + ) let center = image.probePixel(atUnitPoint: CGPoint(x: 0.5, y: 0.5)) #expect(center.green > 0.5) #expect(center.red < 0.5) diff --git a/Shared/SnapshotKitTesting/Tests/SnapshotRenderingSupportTests.swift b/Shared/SnapshotKitTesting/Tests/SnapshotRenderingSupportTests.swift new file mode 100644 index 00000000..21d42fa4 --- /dev/null +++ b/Shared/SnapshotKitTesting/Tests/SnapshotRenderingSupportTests.swift @@ -0,0 +1,78 @@ +@_spi(Testing) import SnapshotKitTesting +import TestHostSupport +import Testing +import UIKit + +/// Regression guards for the settle loop's ending conditions. The budget bounds +/// *observed motion*, not proof-of-stability: a change-free loop that runs out +/// of budget before completing the three passes stability needs keeps going +/// until it can prove stability (CI reproduced the alternative — a static +/// screen failing as "still changing" on a starved runner), while content that +/// was actually seen changing still times out at the budget, and content that +/// can never be sampled ends `.starved` at the hard cap instead of hanging. +@MainActor +struct SnapshotRenderingSupportTests { + /// A budget shorter than the quiet window means no run can prove stability + /// inside it — the pre-fix loop always returned `.timedOut` here despite + /// the content never once changing. + @Test func staticContentSettlesPastABudgetTooShortToProveStability() async throws { + let window = try hostWindow() + let view = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.backgroundColor = .systemRed + window.addSubview(view) + defer { view.removeFromSuperview() } + + let outcome = await settleContent(view, minDuration: 0, maxDuration: 0.1) + #expect(outcome == .settled) + } + + @Test func contentObservedChangingStillTimesOutAtTheBudget() async throws { + let window = try hostWindow() + let view = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + window.addSubview(view) + defer { view.removeFromSuperview() } + + // A monotone hue walk: every firing paints a color the loop has never + // sampled, so each pass reads as a content change (alternating two + // colors could alias with the sampling cadence). + var hue: CGFloat = 0 + let timer = Timer.scheduledTimer(withTimeInterval: 0.008, repeats: true) { _ in + MainActor.assumeIsolated { + hue += 0.01 + view.backgroundColor = UIColor( + hue: hue.truncatingRemainder(dividingBy: 1), + saturation: 1, + brightness: 1, + alpha: 1, + ) + } + } + defer { timer.invalidate() } + + let outcome = await settleContent(view, minDuration: 0, maxDuration: 0.15) + #expect(outcome == .timedOut(budget: 0.15)) + } + + /// A zero-sized view yields no render sample, so no change is ever observed + /// and no stability can ever be proven — the loop must give up at the hard + /// cap as `.starved` rather than hang or misreport motion. + @Test func unsampleableContentEndsStarvedAtTheHardCap() async throws { + let window = try hostWindow() + let view = UIView(frame: .zero) + window.addSubview(view) + defer { view.removeFromSuperview() } + + let outcome = await settleContent(view, minDuration: 0, maxDuration: 0.05) + guard case let .starved(passes, cap) = outcome else { + Issue.record("expected .starved, got \(outcome)") + return + } + #expect(passes > 0) + #expect(abs(cap - 0.2) < 0.001, "the hard cap is four budgets") + } + + private func hostWindow() throws -> UIWindow { + try waitFor { hostKeyWindow() != nil } + return try #require(hostKeyWindow()) + } +} diff --git a/Where/WhereUI/SnapshotTests/SnapshotCaptureFlagProbeTests.swift b/Where/WhereUI/SnapshotTests/SnapshotCaptureFlagProbeTests.swift index 3d47c54b..5095dfad 100644 --- a/Where/WhereUI/SnapshotTests/SnapshotCaptureFlagProbeTests.swift +++ b/Where/WhereUI/SnapshotTests/SnapshotCaptureFlagProbeTests.swift @@ -33,7 +33,11 @@ struct SnapshotCaptureFlagProbeTests { rootView: SnapshotCaptureFlagProbe().frame(width: 100, height: 100), ) host.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - let image = await renderSnapshotImage(of: host, safeAreaInsets: .zero) + let image = await renderSnapshotImage( + of: host, + named: "whereui-capture-flag-probe", + safeAreaInsets: .zero, + ) let center = image.probePixel(atUnitPoint: CGPoint(x: 0.5, y: 0.5)) #expect(center.green > 0.5) #expect(center.red < 0.5)