Skip to content

Add matrixed image snapshot testing for WhereUI#101

Open
kyleve wants to merge 43 commits into
mainfrom
snapshot-testing
Open

Add matrixed image snapshot testing for WhereUI#101
kyleve wants to merge 43 commits into
mainfrom
snapshot-testing

Conversation

@kyleve

@kyleve kyleve commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Adds a matrixed image snapshot-testing system for WhereUI: a two-module framework, full suites covering every screen/widget/app-flow surface, LFS-backed references, a dedicated CI job, and a hardening pass that made the suite deterministic across consecutive runs.

Framework

  • Shared/SnapshotKit (shippable; SwiftUI/UIKit only, no test machinery) owns the appearance matrix: SnapshotConfiguration axes for color scheme, Dynamic Type, contrast, layout direction (rtl), legibility weight (bold), device frames (iPhone/iPad/component/fullContent/iPhoneNotched with simulated safe-area insets), and standard vs. VoiceOver-annotated captures. Identifiers omit default axes (a wire format for reference filenames).
  • SnapshotProviding + snapshotPreviews: a view declares its matrix once; the same declaration drives the #Preview cutsheet and the tests, so previews show exactly what CI asserts.
  • \.isCapturingSnapshot: a trait-bridged environment flag with a documented contract — views may only use it to freeze never-settling motion at a deterministic phase, plus a narrow carve-out for content no settle window can fix (live map tiles, wall-clock-dependent system controls) which may substitute placeholders of identical layout.
  • Shared/SnapshotKitTesting (test-only) owns capture + compare: an async render pipeline (settle loop on pixel stability, safe-area override, animation quiescing, cursor hiding, tile-and-stitch for >2000pt content), accessibility annotation, and the assertSnapshots matrix runner.

Suites, storage, CI

  • WhereUISnapshotTests replaces the old hosting smoke tests: every top-level screen, widget, and app-flow surface (launch, onboarding, logged-in root) is pinned across the matrix (~200 references).
  • References live in Git LFS under SnapshotTests/__Snapshots__/; recording is a failure by design so a recording run can't pass as green.
  • A dedicated snapshot CI job runs the standalone scheme (kept out of Stuff-iOS-Tests), checks out with lfs: true, and uploads xcresult/crash diagnostics on failure.

Hardening pass

Determinism:

  • Compare on-disk sRGB PNG bytes (kills the wide-gamut in-memory vs. reference flake).
  • Disable SwiftUI transaction animations at the capture root; settle-loop diet + per-case settle axis (.settled / .settledAtLeast(minDuration:) / .immediate).
  • Fixed flakes at the source: capture-time calendar scrolling skipped, frozen log-viewer fixture, deterministic map stand-in, deterministic date-picker stand-ins (the compact DatePicker renders relative to today's date), and a raised settle floor that outlasts the iOS 26 glass toolbar material adaptation.

Ergonomics:

  • Fail fast, with one clear issue, on the wrong simulator or duplicate reference names.
  • Env-var record modes (TEST_RUNNER_SNAPSHOT_RECORD=failed|all|missing|never) and a configurable diff tool (SNAPSHOT_DIFF_TOOL=ksdiff).
  • onReadyToSnapshot pre-capture hook (runs after settle, re-settled before capture) for focus/presented states.
  • New axes: RTL + bold text (applied in lockstep across preview traits, SwiftUI environment, and UIKit trait overrides) and the iPhoneNotched inset frame; exemplar RTL reference on the settings screen.
  • Full-content scroll capture (Frame.fullContent) renders whole scrollable content in one image.
  • Parallel CI was evaluated and deliberately not enabled: xcodebuild schedules all tests onto one clone and interleaves them in the single-window host, corrupting captures and running slower; the workflow comment records the verification.

Verification

  • Consecutive full-suite runs green (37 tests, ~200 image assertions) with zero reference modifications, on top of per-phase double verifies.
  • Stuff-iOS-Tests (all unit bundles) green.

kyleve and others added 30 commits July 15, 2026 20:22
Create the reusable Shared/ snapshot-testing pair: SnapshotKit (generic,
shippable; SwiftUI/Foundation/UIKit only) and SnapshotKitTesting (test-only;
links swift-snapshot-testing + cashapp/AccessibilitySnapshot, re-exports both
plus SnapshotKit). Wire the WhereUISnapshotTests bundle under
Where/WhereUI/SnapshotTests with its own testScheme (kept out of the
Stuff-iOS-Tests scheme), add a SnapshotKitTests bundle to Stuff-iOS-Tests, and
add a dedicated 'snapshot' CI job (needs: format, checkout lfs: true) so image
snapshots stay out of the main test job.

Route **/__Snapshots__/**/*.png to Git LFS via a scoped root .gitattributes
(existing app-icon PNGs stay plain blobs). A smoke snapshot test proves the
pipeline end to end: it records, then passes against, an LFS-stored reference.

Closes plan step: scaffold-modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
Build the generic appearance matrix: a Hashable SnapshotConfiguration over
colorScheme, dynamicType, contrast, device Frame, and snapshotType
(.standard/.accessibility); a combinations() Cartesian-product builder with
per-axis default singletons; additive .componentDefaults/.screenDefaults presets;
identifierParts that omit default axes (so only dark/ax5/contrast/accessibility/
iPad reach reference-image names); a uiTraitCollection bridge; and a
snapshotTraits(_:) modifier (increased contrast via a UIKit trait override, since
SwiftUI has no setter). Cover the combination counts and identifier omission in
SnapshotKitTests.

Closes plan step: matrix-config.

Co-authored-by: Cursor <cursoragent@cursor.com>
A SnapshotCase (name + configurations + content) that is also a View, so it
renders a labeled cutsheet of its variants; a SnapshotCaseBuilder result builder
so providers declare cases without array boilerplate; the SnapshotProviding
protocol; and a snapshotPreviews cutsheet that renders every case's
non-accessibility variants for an Xcode #Preview (accessibility captures need the
test-only VoiceOver parser, so they're filtered out). Covered by SnapshotKitTests.

Closes plan step: provider-cutsheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add the capture pipeline: renderSnapshotImage + a Snapshotting<UIViewController,
UIImage>.snapshotKitImage strategy that renders any view at any size on the fixed
CI simulator. Ports the workarounds needed for deterministic, device-independent
captures: safe-area-inset swizzling, CATransaction.performWithoutAnimation,
animation quiescing, text-cursor hiding, waitForStableSize for SwiftUI hosting
controllers, and an AccessibilitySnapshotViewController wrapper (cashapp
AccessibilitySnapshotCore) for .accessibility captures.

A development probe confirmed UIKit still returns a blank image for views taller
than ~2000pt on iOS 26.2, so tile-and-stitch (SnapshotWrappingViewController +
tileAndStitchImage) is retained and guarded by LargeViewCaptureTests (short =
one tile, 3000pt = multiple tiles). Precision defaults: precision 0.999,
perceptualPrecision 0.98.

Closes plan step: render-pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
Runner (provider + inline overloads) maps each SnapshotConfiguration to a
trait-configured, sized hosting controller and asserts through the pipeline
strategy. Makes SnapshotCase.init / SnapshotProviding.snapshots MainActor so
authors can build SwiftUI content directly.

Co-authored-by: Cursor <cursoragent@cursor.com>
Wraps snapshot content in whereBroadwayRoot() so WhereUI SnapshotProviding
authors get trait-accurate stylesheet tokens without repeating the root
wrapper. Closes the whereui-adapter plan step.

Co-authored-by: Cursor <cursoragent@cursor.com>
The render pipeline now suspends (async/await) instead of pumping the
run loop synchronously: SwiftUI .task bodies are main-actor concurrency
jobs on the non-reentrant main queue, so a nested RunLoop.run() could
never interleave them and .task-driven content snapshotted as its
placeholder. Suspending frees the main actor so content loads.

The safe-area override is scoped to the captured root (and the capture
scaffolding above it) only; views inside the hosted tree keep the native
implementation so interior contributions — bars, safeAreaInset
accessories, additionalSafeAreaInsets — still compose on top of the
zeroed base.

Before capture, a settle loop renders quarter-resolution pixel digests
and waits until two consecutive frames are byte-identical (bounded by a
min/max duration), so finite reveals and async loads finish while views
that never quiesce can't hang the capture.

Includes regression tests: AsyncContentCaptureTests proves a .task-driven
view captures its loaded state; LargeViewCaptureTests covers tiled
capture through the async path; SnapshotPixelProbe is the shared
pixel-sampling helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
Views that animate forever (repeatForever pulses,
TimelineView(.animation) loops) can never satisfy the pipeline's
pixel-stability settle loop, so their captures land mid-phase. The new
\.isCapturingSnapshot environment value lets such a view render a
deterministic end-state of motion during capture. The contract lives on
the property: read it only to freeze motion at a canonical phase — never
to change layout, content, or behavior; the settle loop remains the
fallback for views that don't opt in.

The key is bridged from a UIKit trait (SnapshotCaptureTrait) rather than
a plain @entry so it survives the pipeline's re-hosting: the capture
pipeline sets traitOverrides on the captured content controller (removed
after capture), and UIKit propagates it into the hosted SwiftUI tree in
every capture path — plain, accessibility-wrapped, and the
intrinsic-measurement probe. The preview cutsheet applies the same flag
via the environment so previews mirror what tests capture.

Co-authored-by: Cursor <cursoragent@cursor.com>
TypewriterText renders the full final text immediately during capture
(the reveal's end state), the same path Reduce Motion takes — its
sentence pauses (340ms) outlast the settle loop's stability window, so a
capture could land mid-reveal.

LaunchSplashView skips the repeatForever icon pulse and freezes the
radar rings at a canonical phase: the TimelineView clock is paused and
the phase pinned to a constant, because a paused TimelineView still
reports the wall-clock pause time, which would leak run-dependent ring
radii/opacities into the image.

AppIconActivityIndicator (the recent-activity loading pulse) adopts the
same pin as the splash pulse — the only other repeatForever in WhereUI.
System ProgressView spinners are deliberately left alone: their motion
isn't ours to freeze, and no snapshot fixture pins one mid-spin.

SnapshotCaptureFlagTests proves a view reading the flag sees true
through the real pipeline (probe view captured via renderSnapshotImage,
pixel-probed green) — including across the WhereUI dynamic-framework
boundary.

Co-authored-by: Cursor <cursoragent@cursor.com>
The repo points core.hooksPath at .githooks (set by ./ide), so the
standard hooks 'git lfs install' writes must be tracked there for
clones to run LFS clean/smudge maintenance. Pure infrastructure, no
behavior change to the tracked pre-commit formatting hook.

Co-authored-by: Cursor <cursoragent@cursor.com>
Every top-level WhereUI screen declares its snapshot matrix via a
SnapshotProviding conformance in ScreenSnapshots.swift (driving the
#Preview cutsheet and the image tests off one declaration), and
ScreenSnapshotTests is one assertSnapshots(of:) call per screen.
WhereSnapshot gains the compact phoneLightDark/componentLightDark
matrices for a screen's secondary states.

PreviewSupport fixtures are pinned for determinism: a fixed referenceNow
(midday Pacific, mid-sample-year) drives every model's now so today
chrome doesn't churn daily; loadedModel() is pre-onboarded over
in-memory preferences so RootView renders logged-in UI and the host's
UserDefaults can't leak in; previewServices() gains a
NoopDataIssueAlertScheduler so the launch sequence can't suspend on a
real notification-permission prompt.

Reference images (ScreenSnapshotTests) are recorded on iPhone 17 /
iOS 26.2 and stored via Git LFS.

Co-authored-by: Cursor <cursoragent@cursor.com>
WidgetSnapshots declares matrices for the widget entry views and
lock-screen accessories over a fixed snapshot day; AppFlowSnapshots
covers the launch splash (default + slow-launch caption, frozen via
isCapturingSnapshot), onboarding, the logged-in root scene, and the
DEBUG SwiftData inspector against a live seeded in-memory store.

WidgetSnapshotTests and AppFlowSnapshotTests are one assertSnapshots
call per surface; reference images recorded on iPhone 17 / iOS 26.2,
stored via Git LFS.

Co-authored-by: Cursor <cursoragent@cursor.com>
Records the four known-nondeterministic areas left after the async
pipeline fix and isCapturingSnapshot adoption: the live-log
debugLogViewer captures, the iPad ax5 sheet-offset shift, the
unexplained in-memory-vs-on-disk comparison discrepancy, and
root.LoggedIn rendering the empty state.

Co-authored-by: Cursor <cursoragent@cursor.com>
The WhereUISnapshotTests suites now cover every screen, widget, and
app-flow surface these hosted 'renders without crashing' tests exercised,
so delete the nil-only hosting tests and keep the behavioral ones:

- Delete ScreenHostingTests, WidgetViewsTests (hosting half),
  LaunchSplashViewTests, WhereUITests (rootViewBuilds), the
  OnboardingView render test, and the inspector render test.
- Keep behavioral coverage: OnboardingModelTests, the inspector
  schema-drift guard, and the WidgetSnapshot ranking tests (moved to
  WidgetSnapshotRankingTests to stay 1:1 with WidgetSnapshot+Ranking);
  widget string checks move into StringsTests.
- Remove TestHostSupport.waitForOneRunloop — its only callers were the
  deleted smoke tests, closing the flake-paradise TODO in Where/TODOs.md.

Stuff-iOS-Tests passes (200 suites, 0 failures).

Co-authored-by: Cursor <cursoragent@cursor.com>
- Root AGENTS.md: note that image snapshot bundles run via standalone
  schemes + the dedicated CI snapshot job (not Stuff-iOS-Tests), that
  __Snapshots__/ references live in Git LFS, and add SnapshotTests/ to
  the module directory skeleton.
- Where/AGENTS.md: point the testing section at WhereUISnapshotTests and
  warn off re-adding hosting smoke tests for covered surfaces.
- WhereUI AGENTS.md + README.md: document the SnapshotTests/ bundle, the
  SnapshotProviding conformances in Sources/Preview/, and how to
  re-record a reference (delete the PNG, run the scheme).
- SnapshotKit / SnapshotKitTesting docs verified accurate; no changes.

Docs-only change; ran ./sync-agents.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pin UIGraphicsImageRendererFormat.preferredRange = .standard on the
tile, stitch, and settle-digest renderers, and round-trip the final
capture through pngData()/UIImage(data:scale:) so the perceptual
compare sees exactly the bytes flushed to disk. Wide-gamut in-memory
captures were diffing against sRGB PNG references while the on-disk
artifacts were pixel-identical — the in-memory-vs-on-disk deltaE flake.

All 199 references re-recorded as 8-bit sRGB (previously 16-bit
Display P3). Remaining verify failures (calendar scroll position,
debugLogViewer live buffer) are addressed by later phases or already
filed in Where/TODOs.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
Apply .transaction { disablesAnimations = true; animation = nil } to
the styled root in makeHostingController, so animated state changes in
hosted trees commit their end state instantly instead of being caught
mid-flight by the capture. The settle loop remains for .task-driven
async content.

Zero re-records: the full suite passes against the phase-1 references
except the two known-cause failures — calendar scroll position
(addressed by phase 4) and debugLogViewer's live log timestamps
(filed in Where/TODOs.md).

Co-authored-by: Cursor <cursoragent@cursor.com>
SnapshotCase (and the whereSnapshot factory) gain a SnapshotSettle axis:
.settled (default) runs the async settle loop; .immediate — for content
that is fully renderable after a layout pass — skips the digest-render
loop entirely, doing one Task.yield plus a re-layout, in both
resolveContentSize and the main capture path. The static widget cases
and the appIcon screen case are marked .immediate (none have
.task-driven content or TypewriterText).

The settle loop now samples at 16ms (was 60ms) against an anchored
120ms quiet window: adjacent-frame comparison at the denser cadence
would declare slow crossfades stable, so stability is judged against
the first sample of the quiet window instead. minDuration rises to
250ms — the iOS 26 glass toolbar/tab bar adapts its material to
content a few hundred ms after hosting, and a lower floor captured the
pre-adaptation glass (primary/root regressions during tuning).

Deviation from the plan's pixel-neutral goal: the two regionMap
references are re-recorded. The new timing consistently captures the
map before MapKit's place labels fade in (the old timing was a coin
flip between mid-fade and loaded). Live-Map tile loading remains
inherently nondeterministic — filed in Where/TODOs.md.

Widget suite: 45.4s -> 29.9s. Full suite roughly neutral (the higher
settle floor offsets the faster cadence on settled screens).

Co-authored-by: Cursor <cursoragent@cursor.com>
The regionMap.Default_iPhone reference recorded during the phase-3
re-record caught a one-off state where MapKit's northern overlay tiles
had not finished rendering; the two runs since both captured the same
more-complete polygon (identical diff bbox), so adopt that capture as
the reference. Live-Map nondeterminism remains filed in Where/TODOs.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
CalendarView reads \.isCapturingSnapshot and skips scrollToCurrentMonth
under capture: the landing offset of a proxy scroll over a LazyVStack
depends on how much lazy content has been measured at scroll time, so
the captured scroll position was nondeterministic (the recurring
calendar.WithData_iPhone / _iPad_ax5 failures). Snapshots now capture
the deterministic top-of-year state — the re-recorded
calendar.WithData_iPhone shows January at the top.

All 10 calendar.* references were deleted and re-recorded; the two
accessibility variants came back byte-identical (they already captured
the unscrolled state), so 8 changed.

Co-authored-by: Cursor <cursoragent@cursor.com>
Move the in-memory CILabDeltaE mismatch item to completed with its
root cause (wide-gamut extended-range captures vs sRGB PNG references,
fixed in the phase-1 sRGB pinning + PNG round-trip), annotate the iPad
ax5 sheet-offset item as likely fixed by the phase-4 capture-time
scroll skip, and extend the regionMap note to cover overlay-tile
completeness as well as label fade-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a Frame.fullContent(name:width:) preset (a new SizeStrategy case
mapping onto the intrinsic content-measured pipeline) that renders a
scroll view's whole content in one image — verified by a probe that a
bare ScrollView measures to its content height under the unbounded
sizeThatFits proposal, so no .fixedSize shim is needed. Content
measurement now iterates to a fixed point so lazy stacks (whose first
measurement is an estimate) materialize fully instead of cutting off;
non-lazy content still measures identically on the first pass.

Adds a probe-based ScrollView regression to LargeViewCaptureTests, a
full-year calendar exemplar (CalendarYearGrid split out of CalendarView
so the case wraps content, not NavigationStack chrome), and its new
calendar.FullYear_fullHeight reference. Existing references unchanged.
Add the @MotionIsStatic property wrapper (Sources/Shared) — a
DynamicProperty deriving one Bool from Reduce Motion OR snapshot
capture — so a view with continuous/looping motion asks a single
question: should it render its static end-state?

Adopt it in TypewriterText, AppIconActivityIndicator, and
LaunchSplashView (including RadarPingBackground's TimelineView pause,
which drops its animated: parameter; the capture-only phase pin keeps
reading \.isCapturingSnapshot, and the one-shot caption fade keeps
honoring Reduce Motion alone). Behavior is identical, and the full
snapshot suite passes pixel-neutral with zero re-records (only the
known debugLogViewer nondeterminism failed, unchanged by this step).

Records the rule in WhereUI's AGENTS.md; CLAUDE.md regenerated via
./sync-agents.
The debugLogViewer snapshot rendered the live shared WhereLog buffer,
so wall-clock timestamps and run-dependent log lines leaked into the
image — it failed every verify run. The case now renders a frozen
fixture: a fresh production LogStore seeded through its @_spi(Testing)
record seam with fixed lines (one per interesting level/category) whose
timestamps pin around PreviewSupport.referenceNow. No production
changes — the injectable seam (LogViewerConfiguration taking any store)
already existed.

Deletes and re-records both debugLogViewer references; the test passed
two consecutive verify runs. Moves the TODOs.md item to completed.
The regionMap snapshot captured a live MapKit Map, whose tile/label
loading is cache/network-dependent — no settle window could pin it.
Under \.isCapturingSnapshot, RegionMapView now renders a deterministic
stand-in of identical layout: the same region-outline polygons, drawn
with the live fill/stroke styling in a Canvas over a flat substrate,
framed by the same enclosing-region math the live camera uses (now
shared via enclosingRegion(of:)). Only the tile substrate is
substituted; the view's own overlays and legend still render for real.

This bends the capture flag's never-change-content contract, so the
contract doc on isCapturingSnapshot (and the SnapshotKit README) now
carves out the exception explicitly: externally-loaded,
non-deterministic content may substitute a deterministic placeholder of
identical layout; everything else must render real content.

Deletes and re-records both regionMap references; the test passed two
consecutive verify runs. Marks the TODOs.md item resolved.
The WhereUISnapshotTests scheme's test action now pins the environment
the LFS reference images were recorded on
(SNAPSHOT_EXPECTED_SIMULATOR_RUNTIME_VERSION=26.2,
SNAPSHOT_EXPECTED_SCREEN_SCALE=3) via a new optional
testEnvironmentVariables parameter on the shared testScheme helper;
other schemes keep an argument-less test action.

Both assertSnapshots entry points guard those expectations against the
live simulator (SIMULATOR_RUNTIME_VERSION + UIScreen.main.scale) before
rendering anything: on a mismatch they record ONE clear issue naming
expected vs actual and assert nothing, instead of failing every
comparison with confusing pixel diffs. Runs without the expectation
variables (direct renderSnapshotImage callers, non-scheme invocations)
skip the guard.

Guard proven to fire by forwarding a bogus expectation with
TEST_RUNNER_SNAPSHOT_EXPECTED_SCREEN_SCALE=2 (every test failed fast
with the mismatch message); full suite green with the real values.

Also files the pre-existing root.LoggedIn glass-adaptation flake under
the deferred snapshot-flakiness notes: it reproduced on an unmodified
tree (1 in ~4 isolated AppFlowSnapshotTests runs), so it is not a
pixel-neutrality regression of this change.

Co-authored-by: Cursor <cursoragent@cursor.com>
Before rendering anything, assertSnapshots(of:) collects every full
reference identifier (case name + configuration identifier, exactly as
references are named on disk) across the provider's cases and refuses
to assert when any collide, recording one issue that lists the
colliding identifiers — a duplicate would silently compare one variant
against the other's reference image.

The collision detection is a pure function
(duplicateSnapshotIdentifiers(in:)) sharing the identifier construction
with the runner via fullSnapshotIdentifier(caseName:configuration:), so
the guard and the on-disk naming can't drift. Unit-tested directly in
DuplicateSnapshotIdentifiersTests, including the cross-case collision
the underscore-joined format permits (Badge + dark vs Badge_dark).

Full snapshot suite green; no reference images touched.

Co-authored-by: Cursor <cursoragent@cursor.com>
When assertSnapshots' record parameter is nil, the runner reads a
SNAPSHOT_RECORD environment variable (all / failed / missing / never,
mapped through SnapshotTestingConfiguration.Record's rawValue init) and
applies it via withSnapshotTesting(record:) around each assert.
Precedence: explicit parameter, then SNAPSHOT_RECORD, then the suite's
.snapshots(record:) trait. An unrecognized value records an issue
rather than silently asserting with the suite default.

Forwarding verified end to end: xcodebuild passes TEST_RUNNER_-prefixed
variables into the test process, so with one reference deleted,
TEST_RUNNER_SNAPSHOT_RECORD=never made the test fail without rewriting
the file (the suite trait alone would have re-recorded it — proving the
env var reached the process and beat the trait), and
TEST_RUNNER_SNAPSHOT_RECORD=missing re-recorded it. The deleted
reference was restored from git; zero net reference changes.

Documented the working invocation
(TEST_RUNNER_SNAPSHOT_RECORD=failed mise exec -- tuist test
WhereUISnapshotTests ...) as the primary re-record flow in the
SnapshotKitTesting and WhereUI READMEs, replacing delete-then-rerun.

Full snapshot suite green; no reference images touched.

Co-authored-by: Cursor <cursoragent@cursor.com>
A SNAPSHOT_DIFF_TOOL environment variable (forwarded as
TEST_RUNNER_SNAPSHOT_DIFF_TOOL on the command line) selects the diff
tool printed in failure messages: ksdiff maps to
SnapshotTestingConfiguration.DiffTool.ksdiff; absent or unknown values
keep the default plain file-URL output. Applied through the same single
withSnapshotTesting(record:diffTool:) call that phase 9's record
override uses, so the two env overrides combine in one wrap.

Documented under "Diffing failures" in the SnapshotKitTesting README.
Full snapshot suite green; no reference images touched.

Co-authored-by: Cursor <cursoragent@cursor.com>
SnapshotCase gains an optional onReadyToSnapshot
(@mainactor () async -> Void) hook (SwiftUI-convenience default of nil,
matching how the settle axis is handled), threaded through both
assertSnapshots entry points and into renderSnapshotImage. The pipeline
invokes it once AFTER the content settles and BEFORE the accessibility
parse / cursor-hiding / capture — the deterministic point to focus a
field or trigger a presented state — then settles the hook's effects
again (same settle mode) so they're committed in the image. Direct
renderSnapshotImage callers keep compiling via the defaulted parameter.

Regression-tested probe-based (no references): PreCaptureHookTests
drives a three-state @observable probe (red until .task fires -> blue,
green only once the hook flips it) and asserts the hook saw the settled
(post-.task) state and that the captured pixels are green. SnapshotCase
storage/default covered in SnapshotKitTests. READMEs for both modules
document the hook and when it runs.

Also documents (Where/TODOs.md, deferred snapshot-flakiness) two
PRE-EXISTING, environment-dependent reference failures uncovered while
verifying — both reproduced at the branch base commit (bcf1a2d) with
zero phase 7-11 changes, so neither is a pixel-neutrality regression of
this work:
  - manualDay.Add_*: the compact DatePicker's displayed format depends
    on the REAL-WORLD date (it sizes its capsule from today's
    medium-format string and renders the selection medium only if it
    fits, else short numeric). References recorded Jul 16 show
    'Jul 15, 2026'; on Jul 17 the same selection renders '7/15/26'.
    Deterministic per-day, not random. Durable fix is a
    \.isCapturingSnapshot date-label stand-in + one re-record (out of
    scope here; would change references).
  - root/primary glass toolbar/tab-bar pre-adaptation flake (~1 in 4).

Phase 11 code verified via PreCaptureHookTests + SnapshotKitTests
(green) and a full-suite run with the two known flakes accounted for;
no reference PNGs added or modified.

Co-authored-by: Cursor <cursoragent@cursor.com>
kyleve and others added 8 commits July 17, 2026 09:41
The compact DatePicker's value capsule renders relative to the
real-world date: it reserves its width from *today's* date in the
medium format and falls back to short numeric ("7/15/26") whenever the
selection's medium string doesn't fit — so the manualDay.Add_*
references, recorded Jul 16 with a pinned Jul 15 selection, failed
deterministically from Jul 17 on, and no settle window could help.

Under \.isCapturingSnapshot the compact pickers now render
SnapshotDatePickerStandIn: the same title + trailing-capsule row with
the selection in a fixed Date.FormatStyle and locale, so the image is a
pure function of the selected value. Applied to every picker a snapshot
case captures — ManualDayView's add-mode date pickers (single-day and
range) and SettingsView's reminder/summary time pickers, which share
the same mechanism family. AddEvidenceView's picker is not captured by
any case today; filed in Where/TODOs.md as latent.

The \.isCapturingSnapshot carve-out (SnapshotCaptureFlag + the
SnapshotKit README) now covers system controls whose rendering depends
on wall-clock state, alongside externally-loaded substrates.

Re-recorded the 22 references containing a substituted capsule:
manualDay.Add_{iPhone,iPad}{,_dark,_contrast,_ax5,_accessibility},
manualDay.AddWithCancel_iPhone{,_dark}, and
settings.Default_{iPhone,iPad}{,_dark,_contrast,_ax5,_accessibility}
(settings.Default_iPhone_ax5 re-recorded byte-identical — the capsule
is below the fold at ax5). Verified green twice in a row; WhereUITests
and swiftformat --lint clean.

Closes the manualDay TODOs entry (moved to completed).

Co-authored-by: Cursor <cursoragent@cursor.com>
The iOS 26 glass toolbar/tab bar adapts its material to the content
behind it a few hundred ms after hosting, starting *quiet* — so the
pixel-stability settle loop could exit at the default 0.25s floor
before the adaptation even began, intermittently capturing the
pre-adaptation light glass pills (root.LoggedIn ~1 in 4 isolated runs;
primary.Loaded_iPhone once).

SnapshotSettle gains .settledAtLeast(minDuration:) — the .settled loop
with a raised minimum window (hang budget extended above the floor so
the minimum is always honored). It's a general per-case knob rather
than a raised global floor, so only cases hosting such chrome pay it:
RootView.LoggedIn and PrimaryView.Loaded opt in at 1.0s.

Measured cost: root() ~4.5s -> ~5.1s (2 captures), primary() ~24s ->
~31s (10 captures); every other settled case keeps the 0.25s floor.

Evidence: 4 consecutive isolated AppFlowSnapshotTests runs green
(untreated baseline flaked ~1 in 4), plus a green primary() run.
SnapshotKitTests green; swiftformat --lint clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
SnapshotConfiguration gains a layoutDirection axis (default
.leftToRight, identifier token rtl) and a legibilityWeight axis
(default .regular, token bold). Both apply in lockstep everywhere a
configuration renders: the SwiftUI environment + mirrored UIKit trait
overrides in the test pipeline's makeHostingController, the
uiTraitCollection bridge, and snapshotTraits for the preview cutsheet.
combinations(...) multiplies the new axes like the existing ones.

Frame gains safeAreaInsets (a small Hashable Insets type; default
.zero so images stay device-independent), threaded through
assertSnapshots into renderSnapshotImage's existing safe-area
override. The .iPhoneNotched preset simulates real device chrome
(Dynamic Island top 47pt, home indicator bottom 34pt); new frame name
token only.

Identifier omission rules unchanged at defaults — the full suite
passes with every existing reference byte-identical, proving the wire
format held. Exemplar: settings.Default gains one RTL iPhone variant
(settings.Default_iPhone_rtl.png, recorded once, verified twice) —
labels right-align, toggles/values mirror to the leading edge.

SnapshotConfigurationTests covers the new tokens, the axis
multiplication, and the frame insets; SnapshotKit README documents
both axes and the notched preset.

Verified: SnapshotKitTests green; full WhereUISnapshotTests green
(37/37) with only the new reference added; swiftformat --lint clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
Verified locally that xcodebuild's parallel testing cannot be enabled
for WhereUISnapshotTests, and documented why in the workflow instead
of adding the flags.

Evidence (Xcode 26 / iOS 26.2 simulator, tuist passthrough, tried both
-maximum-parallel-testing-workers 2 and -parallel-testing-worker-count
2): xcodebuild scheduled all 45 tests onto a single simulator clone
('Clone 1', one StuffTestHost process), but enabled concurrent test
scheduling inside that process. The capture pipeline is single-tenant
per process — every test renders into the one StuffTestHost key window
and the settle phase suspends the main actor — so concurrent tests
interleave mid-capture and corrupt each other's images: 24 spurious
'does not match reference' failures (plus the LargeViewCapture size
probe reading another test's content), and wall time got WORSE
(407s and 1004s vs ~330s serial; the failure-retry churn dominates).

Since parallelism never crossed process boundaries in practice, the
per-process single-window model can't benefit; the serial run stays.
The snapshot job keeps its serial command with a comment recording
the verification so nobody re-tries the flags blind.

Co-authored-by: Cursor <cursoragent@cursor.com>
Brings every snapshot doc current with the knobs the hardening phases
added, and fixes stale text:

- SnapshotKitTesting AGENTS.md: the 'single Snapshotting strategy'
  invariant was stale — the pipeline is the async
  renderSnapshotImage(...) function (its async is load-bearing for
  settling). Added the missing invariants: PNG round-trip compare,
  fail-fast setup guards (simulator pin, duplicate identifiers), and
  the single-tenant-capture reason the suite must stay serial.
- SnapshotKitTesting README: documents the fail-fast guards and the
  frame-declared safe-area insets in the pipeline summary.
- SnapshotKit AGENTS.md: identifier-token examples include the new
  rtl/bold tokens plus the add-an-axis-safely rule; the capture-flag
  invariant now names the placeholder carve-out.
- WhereUI README/AGENTS.md: matrix axis list includes RTL and bold
  text; the re-record flow mentions the TEST_RUNNER_SNAPSHOT_RECORD
  bulk path alongside delete-and-rerun.

Ran ./sync-agents; swiftformat --lint clean.

Verified beforehand: two consecutive full WhereUISnapshotTests runs
green (37/37 each, zero reference modifications) and Stuff-iOS-Tests
green.

Co-authored-by: Cursor <cursoragent@cursor.com>
Conflict resolutions:
- AGENTS.md, Where/TODOs.md: clean union of both sides' additions
  (main's synthesized-Codable + composition-root sections and new TODO
  entries alongside our snapshot-testing docs and flakiness ledger).
- LaunchSplashView.swift: main's honest-splash copy (launch-neutral
  "Getting things ready…" caption strings + doc) combined with our
  snapshot determinism work (@MotionIsStatic, radar phase pinned to 0
  under \.isCapturingSnapshot, previewShowsCaption seam).
- PreviewSupport.swift: main's FlightDayIssue sample fixture kept
  alongside our pinned referenceNow dates, pre-onboarded loadedModel,
  and Noop scheduler injection.
- SettingsView.swift: main's "Find issues now" scan section combined
  with our SnapshotDatePickerStandIn capture substitution.
- ScreenHostingTests.swift (modify/delete): kept our deletion — the
  hosting smoke tests were superseded by WhereUISnapshotTests. Main's
  new flightDayDetailViewHosts coverage is ported as a snapshot case
  instead: FlightDayDetailView gains a SnapshotProviding conformance
  (day pinned to referenceNow) and a ScreenSnapshotTests.flightDayDetail
  test with 10 new references.

Re-recorded references (all legitimate upstream UI changes):
- launchSplash.SlowLaunchCaption_iPhone{,_dark}: caption copy changed
  from "Updating your data…" to "Getting things ready…" (#99).
- settings.Default_iPad{,_dark,_contrast,_accessibility}: new
  "Find issues now" row in the Data resolution section (#90); the
  iPhone variants are unchanged because that section sits below the
  captured fold there.
- resolution.WithIssues_{iPhone,iPad}{,_dark,_contrast,_accessibility}
  and resolution.WithIssues_iPad_ax5: new "Flights" section renders the
  flight-day sample issue (#90); iPhone_ax5 unchanged (below the fold).

Stuff-iOS-Tests passes. Snapshot suite: failures triaged in a full
diagnostic run, re-recorded references verified green with a targeted
run; the final all-green full-suite pass was skipped at user request —
PR CI serves as the full-suite verification.

Co-authored-by: Cursor <cursoragent@cursor.com>
The LFS reference images bake the recording machine's Pacific wall-clock
dates/times into pixels (widget day labels, log-viewer timestamps, the
SwiftData-inspector seed row), so an unpinned UTC CI runner would shift
every date-rendering snapshot into a wall of confusing image diffs.

- Project.swift: the WhereUISnapshotTests scheme's test action now sets
  TZ=America/Los_Angeles (the pin) and SNAPSHOT_EXPECTED_TIMEZONE (the
  guard), alongside the existing SNAPSHOT_EXPECTED_* simulator pins.
- AssertSnapshots.swift: simulatorMatchesSnapshotExpectations() gains a
  timezone arm — if TimeZone.current doesn't match the expectation, the
  runner records one clear issue and asserts nothing, same as the
  runtime/scale mismatches. This permanently verifies the TZ pin
  actually reaches the test process.
- SnapshotKitTesting README: document the timezone pin requirement.

Verified: with a temporary TZ=UTC probe in the scheme, all five
WidgetSnapshotTests failed on exactly the new timezone-mismatch guard
(proving TZ propagates through the test action into the test process);
with the Pacific pin, WidgetSnapshotTests, calendar, manualDay, and
debugLogViewer all pass against the existing references — no re-record.

Fixes the app-side review's High finding (H1) from the July 2026
snapshot-testing PR review.

Co-authored-by: Cursor <cursoragent@cursor.com>
The pipeline is @mainactor async with long suspensions (settle loops,
pre-capture hooks) while holding process-global state: the safe-area
swizzle is a method_exchangeImplementations parity toggle plus two
override globals, UIView.setAnimationsEnabled is a save/restore with the
same nesting hazard, and every capture renders into the one StuffTestHost
key window. Interleaved captures corrupt all of it — proven by the Phase
13 parallel experiment (commit 71c0d36, 24+ spurious mismatches) — but
until now the only defenses were prose and xcodebuild's serial default.

- SnapshotCaptureLock (new): a FIFO @mainactor mutex; renderSnapshotImage
  runs its whole body under it, so a concurrent capture queues instead of
  corrupting the in-flight one. Re-entry from a task already holding the
  lock (a hook rendering another snapshot) is a programmer error and traps
  instead of deadlocking.
- SafeAreaInsetsSwizzling: the method exchange is now depth-counted —
  only the 0-to-1 and 1-to-0 transitions exchange implementations, so
  nested/unbalanced pairs can't flip parity; an unpaired unswizzle traps.
- ConcurrentCaptureTests (new, WhereUISnapshotTests): captures a
  safe-area-sensitive probe view under 20pt and 0pt inset overrides,
  serially then concurrently from one task group, and requires identical
  probed pixels plus unchanged host-window insets afterward. Verified the
  guard bites: with the lock temporarily bypassed the test fails on the
  interleaving corruption; with it, it passes.
- SnapshotKitTesting README/AGENTS.md: the single-tenant rule is now
  documented as code-enforced, not convention.

Verified: SnapshotKitTests green; full WhereUISnapshotTests suite green
against the existing references (pixel-neutral, no re-record).

Fixes the framework review's High finding H1 from the July 2026
snapshot-testing PR review.

Co-authored-by: Cursor <cursoragent@cursor.com>
/// Snapshot captures substitute the compact time pickers' value capsules
/// with a deterministic stand-in — the live capsule's rendering depends on
/// real-world clock state (see `SnapshotDatePickerStandIn`).
@Environment(\.isCapturingSnapshot) private var isCapturingSnapshot

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of exposing this in product code, let's create a WhereDatePicker view that checks this internally and shows the deterministic stand-in, to avoid leaking snapshot environment checking to product UI.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, would another option be to pre-set the date to something known instead of creating our own fake? (We should still wrap the date picker, but then our wrapper could set its date in snapshots vs creating its own fake version)

The WhereUISnapshotTests comment in Project.swift claimed SnapshotKit
arrives only transitively through WhereUI — but listing
SnapshotKitTesting in extraPackageProducts statically embeds its
dependency closure (SnapshotKit included) into the .xctest, while
WhereUI (a dynamic framework) carries its own copy. That is the
duplicate-type-metadata hazard from the root AGENTS.md Targets note,
and \.isCapturingSnapshot is exactly the type-keyed cross-boundary
lookup it warns about: the pipeline writes SnapshotCaptureTrait via the
bundle's copy while WhereUI's stand-ins (MotionIsStatic, RegionMapView,
SnapshotDatePickerStandIn) read via WhereUI's. It works today, but a
silent future split would revert every stand-in to nondeterministic
rendering, and the existing SnapshotCaptureFlagTests probes a view
compiled into the bundle — the same image as the writer — so it could
never catch that split.

- Project.swift: replace the misleading comment with the real topology
  and why the duplicate embed is tolerated (the pipeline has no other
  route into the bundle; the trait lookup demonstrably resolves across
  both copies today).
- SnapshotCaptureFlagProbe (new, WhereUI, DEBUG-only): a WhereUI-defined
  view that renders green iff \.isCapturingSnapshot is true.
- SnapshotCaptureFlagProbeTests (new, WhereUISnapshotTests): pixel-probes
  that view through renderSnapshotImage — the cross-boundary guard,
  modeled on WhereStylesheetTests.resolvesTraitAwareTokensFromTheBroadwayRoot.
  The existing same-image SnapshotCaptureFlagTests stays, its doc now
  naming the scope split.
- SnapshotKitTesting/AGENTS.md: record the tolerated duplicate embed as
  a module invariant.

Verified: SnapshotCaptureFlagProbeTests + SnapshotCaptureFlagTests green
in a targeted WhereUISnapshotTests run.

Fixes the framework review's High finding H2 from the July 2026
snapshot-testing PR review.

Co-authored-by: Cursor <cursoragent@cursor.com>
@propertyWrapper
struct MotionIsStatic: DynamicProperty {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.isCapturingSnapshot) private var isCapturingSnapshot

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we confirm these lookups actually work in property wrappers?

kyleve and others added 4 commits July 17, 2026 15:23
Docs-only commit: no code changes. Every Medium and Low finding from the
two-part July 2026 snapshot-testing PR review (framework + app-side) is
recorded durably; the three High findings were fixed in the preceding
three commits and are not re-filed.

- Shared/SnapshotKitTesting/TODOs.md (new): silent settle-loop timeout
  (M1), accessibility-parse preconditionFailures killing the shared host
  process (M2), the pipeline coverage gaps (M4: iPhoneNotched preset via
  the config mapping, lazy-container measurement iteration, env-parsing
  seam, 2000pt tile seam), cancellation mid-settle (L1), the unused
  AccessibilitySnapshot umbrella dependency (L2), wall-clock settle
  deadlines (L3), and the inline overload's missing duplicate-identifier
  guard (L4).
- Shared/SnapshotKit/TODOs.md (new): case content/models built once and
  shared across every configuration (M3).
- Where/TODOs.md: launchSplash caption-timer race and the appIcon
  .immediate settle join the deferred-flakiness list; ManualDayView
  range-mode coverage loss and the RegionMapView live-Map coverage gap
  join P1s; the onboarding fixture's real-UserDefaults leak, the
  duplicated pinned-instant fixtures, the stale cutsheet/bold-text doc
  claims, and the PrimaryView-empty/RecentActivity-loading matrix gaps
  join P2s. Also moved the 'Add snapshot images to a new test target' P0
  to Completed (the review's L4 — this branch is that work).

Co-authored-by: Cursor <cursoragent@cursor.com>
Under snapshot capture, LaunchSplashView's .task now returns before
starting the 500ms slow-launch caption timer (guarded on
\.isCapturingSnapshot), so whether the caption is visible at capture
time is decided solely by the explicit previewShowsCaption seam —
never by a race between the wall-clock timer and the settle loop's
variable duration. Both caption states keep their own snapshot cases
(launchSplash.Default without, launchSplash.SlowLaunchCaption with),
and both existing references reproduce exactly — no re-record.

The read uses \.isCapturingSnapshot directly rather than
@MotionIsStatic: Reduce Motion must not suppress the caption (those
users still get it, just without the fade), so this isn't a motion
end-state — it's the capture-only determinism carve-out. The
capture-flag contract doc (SnapshotCaptureFlag, SnapshotKit
README/AGENTS) now records wall-clock timers that flip visible state
as part of that carve-out, and the Where/TODOs.md ledger entry moves
to the resolved section.

Verified with 6 consecutive green isolated AppFlowSnapshotTests runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Three consecutive CI failures on root.LoggedIn_iPhone (runs 29617740607,
29621920437 x2) showed the flake class is broader than the glass
pre-adaptation pills the 1.0s settledAtLeast floor targeted: the failure
artifacts baked the launch splash in one run (the runner never reached
.ready inside the settle window) and the pre-activation tab UI in
another (sample-report cards + Elsewhere tab, before MainTabs'
activate() re-pulled the empty store and the Resolve badge appeared).

Mechanism: the logged-in root is multi-phase async work — splash →
launch steps → .ready → MainTabs activation → glass-material adaptation
— and every phase is pixel-quiet under capture (motion frozen,
animations disabled), so the pixel-stability loop legitimately exits
inside any inter-phase gap longer than its 0.12s quiet window. A raised
floor only rescales that race on slow CI runners.

Fix: whereSnapshot now passes through SnapshotCase's onReadyToSnapshot
hook, and RootView.LoggedIn awaits launcher.run() in it — run() is
idempotent and awaits the in-flight drive, a deterministic "reached
.ready" signal — so the re-settle after the hook (floor raised to
1.5s) only has to outlast the post-ready tail: MainTabs' .task
activation and the glass adaptation, which have no reachable completion
signal (ledgered as a follow-up in Where/TODOs.md).

Evidence: 6 consecutive green isolated AppFlowSnapshotTests runs
(root() ~8.6-8.9s); references unchanged; swiftformat --lint clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
Conflict resolutions:
- RegionMapView.swift: kept both sides' environment reads — our
  \.isCapturingSnapshot (SnapshotMapStandIn substitution) alongside
  main's \.regionStyles (data-driven per-region tints, #95).
- SettingsView.swift: import-block conflict only — kept both SnapshotKit
  (our SnapshotDatePickerStandIn substitution) and RegionKit (main's new
  Regions section + RegionsSettingsView sheet). Both features coexist in
  the body via git's auto-merge.
- AGENTS.md, WhereUI AGENTS.md/README.md, ManualDayView, CalendarView,
  PreviewSupport auto-merged cleanly: our snapshot-testing docs/stand-ins
  sit alongside main's flaky-tool docs, PR-description rule, region-style
  resolver docs, grouped region sections, and the
  primaryRegionSelectionModel fixture.

No new stand-ins were needed: main's new snapshot-visible surfaces
(grouped region toggles, the Settings Regions row, restore-from-backup
button) are deterministic; the region-picker's live Map is not part of
any captured snapshot case.

Re-recorded references (all legitimate upstream UI changes from #95):
- onboarding.Default_* (10): the intro adds the 'Restore from a backup'
  button under Continue, shifting the page content up.
- secondary.Loaded_* (7): the EU card's stamp symbol changed from
  star.circle.fill to star.fill (RegionAppearanceCatalog's default).
- settings.Default_* (10): new Regions section between Location and
  Reminders (iPhone_ax5 unchanged — below the captured fold).
- manualDay.* (16): region toggles are now grouped (Your regions /
  More regions disclosure) instead of one flat catalog list.

Stuff-iOS-Tests passes. Snapshot suite: failures triaged in a full
diagnostic run (artifact-vs-reference inspected for each), re-recorded
references verified green with a targeted run; the final all-green
full-suite pass was skipped per standing instruction — PR CI serves as
the full-suite verification.

Co-authored-by: Cursor <cursoragent@cursor.com>
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