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)