Skip to content

Snapshot testing: review-comment cleanup (structure, product-code de-leak, bridging)#113

Open
kyleve wants to merge 8 commits into
snapshot-testingfrom
cursor/snapshot-review-cleanup
Open

Snapshot testing: review-comment cleanup (structure, product-code de-leak, bridging)#113
kyleve wants to merge 8 commits into
snapshot-testingfrom
cursor/snapshot-review-cleanup

Conversation

@kyleve

@kyleve kyleve commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Addresses the review comments on #101, stacked onto that branch (base snapshot-testing). The theme was structural — where tests live, where snapshot matrices are declared, and product code branching on the capture flag — plus a few pipeline-correctness questions.

What changed

Generic pipeline tests moved to their own module (was: in WhereUI).
SnapshotKitTesting had no test bundle; its generic regression tests lived in WhereUISnapshotTests. New SnapshotKitTestingTests bundle (wired in Project.swift, in the Stuff-iOS-Tests scheme) now owns them: async-content settle, concurrent-capture serialization, duplicate identifiers, tile-and-stitch / full-content, the pre-capture hook, the same-image capture-flag probe, and a new safe-area composition regression test (the swizzle zeroes the captured root while an interior safeAreaInset still composes — verifying SafeAreaInsetsSwizzling). PixelSample/probePixel promoted to @_spi(Testing) API shared by both bundles. They assert on probed pixels (no LFS refs), so the bundle runs in the normal test job. The WhereUI-defined cross-boundary flag probe stays in WhereUI (only it can detect a duplicate-SnapshotKit split).

Snapshot matrices moved into each view's own file, driving the preview cutsheet.
Each SnapshotProviding conformance moved out of Sources/Preview/{Screen,Widget,AppFlow}Snapshots.swift into its view file under #if DEBUG, and each view's #Preview now renders Self.snapshotPreviews — so one declaration drives both the Xcode cutsheet and the image tests (the architecture's whole point). The three *Snapshots.swift files are deleted; WhereSnapshot.swift's helpers stay.

One FooSnapshotTests per view, each with its own on-disk directory.
The omnibus Screen/Widget/AppFlow suites split into per-view suites in their own files. Because each reuses the original @Test function name, relocating the ~210 LFS references was a pure git mv — no renames, no re-recording (a separate, byte-clean commit).

Product code no longer branches on \.isCapturingSnapshot.

  • New WhereDatePicker wrapper makes the live-vs-stand-in choice internally (SnapshotDatePickerStandIn demoted to a fileprivate detail); SettingsView/ManualDayView use it and drop the capture flag.
  • RegionMapView's capture branch moved into a private RegionOutlinesMap subview, so the screen no longer reads the flag.
  • LaunchSplashView: documented why it needs both motionIsStatic (freezes the pulse) and isCapturingSnapshot (gates the wall-clock caption timer, which Reduce-Motion users must still get), and extracted the radar phase-clock into a fileprivate TimelineViewDefaultContext extension.

Synchronous flag bridging (the Broadway pattern).
SnapshotCaptureFlag is now a hybrid accessor like BContext+SwiftUI: a pure-SwiftUI EnvironmentKey read first (synchronous — the cutsheet's .environment(\.isCapturingSnapshot, true) no longer pays a UITraitCollection round-trip), falling back to the trait-bridged key (how the pipeline's traitOverrides reaches SwiftUI, and how the cross-boundary probe works). Also: confirmed/commented that MotionIsStatic's nested @Environment reads resolve (it's a DynamicProperty), and deleted the unused empty enum SnapshotKit {}.

Pipeline-correctness questions answered in-code (no behavior change): hostKeyWindow() is the stamped StuffTestHost window (not the current key window); the settle loop compares quarter-res samples byte-exact on purpose (it asks "stopped changing?", not "matches reference?"); hideTextInputCursors targets only text inputs because tintColor inherits (clearing the root would blank every accent-tinted control).

Folded-in review findings (already-filed TODOs): centralized the pinned widget instant on PreviewSupport; fixed the Onboarding fixture's real-UserDefaults leak (PreviewSupport.onboardingModel() over in-memory prefs); filed the CalendarView capture-time scroll-skip smell as a TODO; docs (AGENTS/README for SnapshotKit, SnapshotKitTesting, WhereUI, root) updated to match.

Verification

  • SnapshotKitTestingTests — green.
  • Stuff-iOS-Tests (all unit bundles, incl. WhereUITests, WhereIntentsTests, Broadway, and the new bundle) — green.
  • WhereUISnapshotTests — green with zero reference-byte changes from the restructure, except resolution.Empty_iPhone{,_dark}: a pre-existing ~0.12% local rendering drift on the all-clear ContentUnavailableView (same dimensions, reference unchanged in git, identical fixture — it fails the same way on the base branch on this machine). Left untouched rather than re-recorded, since re-recording locally would diverge from CI's renderer. Worth a confirm on CI / the reference environment.

Deliberately deferred (kept in Where/TODOs.md)

  • Snapshot matrix gaps (PrimaryView empty, RecentActivitySummaryView.loading, ManualDayView range-add) and a live-Map hosting test — all need new reference images (or reintroduce a hosting smoke test the snapshot PR removed). Given the resolution.Empty drift above, recording references locally risks CI divergence, so these are held for the reference environment.
  • Moving the pinned instant off the Feb-1 day boundary (would re-record the widget refs).

I did not post individual replies on the #101 review threads (that's 28 comments on your PR) — happy to, just say the word; this description is the full comment-to-change mapping in the meantime.

Open in Web Open in Cursor 

kyleve added 8 commits July 20, 2026 19:10
…ndle

The capture/compare pipeline had no test bundle of its own; its generic
regression tests (async settle, concurrent-capture serialization, duplicate
identifiers, tile-and-stitch/full-content, the pre-capture hook, and the
same-image capture-flag probe) lived in WhereUISnapshotTests despite being
WhereUI-free and asserting on probed pixels rather than LFS references.

Move them into a new Shared/SnapshotKitTesting/Tests + SnapshotKitTestingTests
bundle, wired into Project.swift and the Stuff-iOS-Tests scheme (fast, no
__Snapshots__, so it runs in the main test job, not the snapshot job). Promote
PixelSample/probePixel into SnapshotKitTesting as @_spi(Testing) API shared by
both bundles, and add a SafeAreaComposition regression test pinning that the
swizzle zeroes the captured root while a safeAreaInset bar still composes on
top. The WhereUI-defined cross-boundary probe test + view stay in WhereUI (they
exist to detect a WhereUI<->bundle SnapshotKit split).

Closes review comments on test placement (Plan to-do: bundle).
… cutsheet

Each view's SnapshotProviding conformance lived in Sources/Preview/{Screen,
Widget,AppFlow}Snapshots.swift, divorced from the view, and no #Preview
rendered the snapshotPreviews cutsheet — so the preview/snapshot single-source
the architecture is built around wasn't actually realized.

Move every conformance into its own view file under #if DEBUG and point the
view's #Preview at Self.snapshotPreviews, so one declaration drives both the
Xcode cutsheet and the image tests. RegionMapView keeps a bespoke live-Map
preview alongside the cutsheet (the cutsheet renders the deterministic
stand-in); RootView keeps its from-scratch launch preview. Delete the three
Preview/*Snapshots.swift files (WhereSnapshot.swift's helpers stay).

Also centralize the pinned widget instant on PreviewSupport (reusing the same
value the references were recorded with) and add PreviewSupport.onboardingModel()
over in-memory preferences, fixing the onboarding fixture's real-UserDefaults
leak as its declaration moves.

Renders identically — the fixtures/config are byte-for-byte the prior
declarations, verified by a full WhereUISnapshotTests run with zero reference
changes (the lone exception, resolution.Empty_iPhone{,_dark}, is a pre-existing
sub-threshold local rendering drift on the all-clear ContentUnavailableView, not
introduced here — the reference is unchanged and the fixture is identical).

Plan to-do: declarations-in-views.
The ScreenSnapshotTests / WidgetSnapshotTests / AppFlowSnapshotTests omnibus
suites lumped every view's references into three shared __Snapshots__/
directories. Split them into one FooSnapshotTests per view, each in its own
file, so each view's reference images live in their own on-disk directory.

Because swift-snapshot-testing keys the directory on the test file's basename
and each per-view suite reuses the original @test function name, relocating the
~210 LFS references is a pure git mv (no renames, no re-recording) — verified by
a full WhereUISnapshotTests run: every suite green from its new directory with
zero reference-byte changes. (The lone red, resolution.Empty_iPhone{,_dark}, is
the pre-existing local rendering drift noted in the prior commit, not the move.)

The two third-party inline surfaces (DebugLogViewer, SwiftDataInspector) get
their own suites too, carrying their bespoke fixtures.

Plan to-do: per-view-tests.
SettingsView and ManualDayView each read \.isCapturingSnapshot and branched
between a live DatePicker and SnapshotDatePickerStandIn inline — leaking
snapshot-environment checks into product UI.

Introduce WhereDatePicker, a compact date/time picker that makes that choice
internally (mapping onto DatePicker's in: range overloads for the live path),
with SnapshotDatePickerStandIn demoted to a fileprivate detail of its file. Both
product views now use WhereDatePicker and no longer import the capture flag.
Renders identically — ManualDayView and SettingsView snapshot suites pass with
no reference changes.

Closes review comments on product code branching on the snapshot flag /
applying the wrapper-type pattern. Plan to-do: wheredatepicker.
… flags

RegionMapView read \.isCapturingSnapshot and branched between the live MapKit
Map and the stand-in inside its own body helper. Move that choice into a private
RegionOutlinesMap subview so the screen no longer reads the capture flag at all —
it just renders the substrate, unaware which one drew. The stand-in's rendering
is unchanged (regionMap references untouched); the live-Map path gains a
dedicated hosting test under the fold-in step.

LaunchSplashView: document why it holds both motionIsStatic (Reduce Motion or
capture — freezes the never-settling pulse) and isCapturingSnapshot (gates the
wall-clock caption timer, which Reduce Motion users must still get), answering
the review question, and extract RadarPingBackground's phase-clock computation
into a fileprivate TimelineViewDefaultContext.radarPhaseClock(capturingSnapshot:)
extension. RegionMap + LaunchSplash snapshot suites pass unchanged.

Plan to-do: map-splash.
…drop dead enum

The capture flag was a pure UITraitBridgedEnvironmentKey, so a SwiftUI-set value
(the preview cutsheet setting .environment for isCapturingSnapshot) paid a
UITraitCollection round-trip / first-frame lag. Adopt Broadway BContext+SwiftUI
pattern: a pure-SwiftUI EnvironmentKey read first, falling back to the
trait-bridged value, with the setter mirroring into both. SwiftUI-set values now
propagate synchronously; the test pipeline traitOverrides path still reaches
SwiftUI via the fallback, and the WhereUI cross-boundary probe still exercises
the bridged key. Verified: SnapshotKitTestingTests (pipeline path),
SnapshotCaptureFlagProbeTests (cross-boundary), and motion-frozen captures
(launchSplash, recentActivity) all pass.

Also document that MotionIsStatic nested @Environment reads resolve (it is a
DynamicProperty), and delete the unused empty enum SnapshotKit namespace file
(SnapshotKitTesting.swift stays; its @_exported imports are load-bearing).

Plan to-do: bridging.
…ions

Answer the review questions on SnapshotKitTesting with in-code rationale (no
behavior change):

- hostKeyWindow(): note it is the window StuffTestHost stamps with
  isMainTestHostWindow (the guaranteed root we set up), not merely the current
  key window, so it is stable across transient key-window changes.
- Settle stability compares quarter-res samples byte-exact, deliberately unlike
  the final image compare tolerance: this loop asks has rendering stopped
  changing (the low-res sample already absorbs sub-pixel jitter), and a
  tolerance would let a slow drift read as settled.
- hideTextInputCursors walks the tree targeting only text inputs rather than
  clearing the root tint once: tintColor inherits, so a clear root tint would
  also blank every accent-tinted control (buttons, links, toggles) and change
  the image.

(The SafeAreaInsetsSwizzling verification landed as SafeAreaCompositionTests in
the SnapshotKitTesting bundle.)

Plan to-do: pipeline-questions.
… smell

- SnapshotKitTesting/AGENTS: replace the now-false 'no dedicated test bundle'
  note with SnapshotKitTestingTests (pixel-probe pipeline regressions, in the
  main Stuff-iOS-Tests scheme).
- SnapshotKit/AGENTS: describe isCapturingSnapshot as the Broadway-style hybrid
  (synchronous SwiftUI key + trait-bridged fallback).
- WhereUI/AGENTS + README: matrices now declared in each view's own file
  (cutsheet via Self.snapshotPreviews), one FooSnapshotTests per view with
  per-view __Snapshots__ dirs; drop the stale 'bold text' matrix mention.
- root AGENTS: note SnapshotKitTestingTests runs in the normal test job (no LFS),
  unlike the image-reference bundles.
- Where/TODOs: file the CalendarView capture-time scroll-skip smell; mark the
  onboarding-leak and preview/cutsheet-claim findings resolved; note the
  pinned-instant de-duplication and why the matrix-gap cases stay deferred
  (local reference recording risks CI drift).

Regenerated CLAUDE.md via ./sync-agents (gitignored). Plan to-do: todo-and-docs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant