Skip to content
Open
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ triage). **Always-on** rules every edit must honor stay in `AGENTS.md` or
- **Image snapshots are the exception: one bundle per module, one shared scheme.** Each module owning image references has its own `*SnapshotTests` target over its `SnapshotTests/` folder, all listed in the single shared **StuffSnapshotTests** scheme and its dedicated CI `snapshot` job — slow and LFS-backed, so deliberately **out of** `Stuff-iOS-Tests`. References under any `__Snapshots__/` directory are Git LFS (`.gitattributes`; the CI job checks out with `lfs: true`). Framework halves: `Shared/SnapshotKit` (shippable matrix + previews) and `Shared/SnapshotKitTesting` (test-only pipeline, whose own regression bundle **SnapshotKitTestingTests** pixel-probes without LFS and runs in `Stuff-iOS-Tests`).
- **A new image suite gets a target, not a scheme.** Add the `*SnapshotTests` target, list only `SnapshotKitTesting` in `extraPackageProducts`, and add it to the `StuffSnapshotTests` scheme's build and test lists — never a scheme or CI job of its own. An image bundle links only what its module needs (the Periscope and Inspector suites don't build against WhereUI at all); references follow the sources automatically via `#filePath`.
- **Separate snapshot bundles are safe because each `.xctest` gets its own `StuffTestHost` process** (measured on Xcode 27 — `ProcessInfo.processIdentifier` probes; details in the snapshot-bundle comment in [`Project.swift`](Project.swift)). Each bundle statically embeds its own copy of `SnapshotKitTesting`'s capture state, and two copies in one process would corrupt each other. Tripwire: if a toolchain ever shares one host process across bundles, re-measure before adding another image bundle.
- **Snapshots containing scrolling content use full-content intrinsic height.** Any image snapshot whose rendered subject contains a `ScrollView`, `List`, `Form`, or equivalent UIKit-backed scrolling container uses SnapshotKit's full-content device presets, which keep the normal device viewport as their minimum height and grow to fit taller content; fixed-height device frames are reserved for subjects without scrolling content. Preserve production navigation, tab, sheet, search, and toolbar chrome when intrinsic measurement converges; an intentionally bounded/greedy container instead snapshots its shared scrolling child directly, never snapshot-only production layout (see `SnapshotConfiguration.Frame.fullContent`).

### Never double-link a product WhereUI already carries

Expand Down
6 changes: 3 additions & 3 deletions Shared/Flyover/SnapshotTests/FlyoverSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ struct FlyoverSnapshotTests {
of: FlyoverView(catalog: catalog),
named: "FlyoverCanvas",
configurations: SnapshotConfiguration.combinations(
devices: [.iPad],
devices: [.iPadFullContent],
colorSchemes: [.light, .dark],
),
// Canvas previews load serially, so a cold CI host can still be resolving
Expand All @@ -24,7 +24,7 @@ struct FlyoverSnapshotTests {
of: FlyoverView(catalog: catalog),
named: "FlyoverCompact",
configurations: SnapshotConfiguration.combinations(
devices: [.iPhone],
devices: [.iPhoneFullContent],
),
)

Expand All @@ -37,7 +37,7 @@ struct FlyoverSnapshotTests {
},
named: "FlyoverList",
configurations: SnapshotConfiguration.combinations(
devices: [.iPad],
devices: [.iPadFullContent],
),
)
}
Expand Down
Comment thread
kyleve marked this conversation as resolved.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions Shared/Inspector/SnapshotTests/InspectorSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ struct InspectorSnapshotTests {
of: swiftDataView,
named: "SwiftData",
configurations: SnapshotConfiguration.combinations(
devices: [.iPhone],
devices: [.iPhoneFullContent],
colorSchemes: [.light],
),
)
Expand Down Expand Up @@ -66,7 +66,7 @@ struct InspectorSnapshotTests {
of: swiftDataView,
named: "SwiftData",
configurations: SnapshotConfiguration.combinations(
devices: [.iPhone],
devices: [.iPhoneFullContent],
colorSchemes: [.dark],
),
)
Expand Down Expand Up @@ -119,7 +119,7 @@ struct InspectorSnapshotTests {
of: rootView,
named: "Root",
configurations: SnapshotConfiguration.combinations(
devices: [.iPhone],
devices: [.iPhoneFullContent],
colorSchemes: [.light, .dark],
),
)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ struct PeriscopeViewerSnapshotTests {
of: viewer,
named: "PeriscopeViewer",
configurations: SnapshotConfiguration.combinations(
devices: [.iPhone],
devices: [.iPhoneFullContent],
colorSchemes: [.light, .dark],
),
)
Expand Down
Comment thread
kyleve marked this conversation as resolved.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions Shared/SnapshotKit/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first.
`SnapshotCaptureFlag.swift`; don't simplify it to a plain `@Entry`.
- **Design-system-agnostic.** SnapshotKit never imports Broadway/WhereUI; the
Broadway root wrap is a consumer concern (`WhereUI`'s `whereSnapshot(...)`).
- **Scrollable subjects use full-content frames.** A snapshot containing a
`ScrollView`, `List`, `Form`, or equivalent UIKit-backed scroller uses
`.fullContentScreenDefaults`, a consumer's matching compact preset, or an
explicit `.fullContent` frame. Device full-content presets keep their normal
viewport height as a minimum and grow only when the settled content is taller;
custom full-content frames shrink-wrap unless given a minimum. Capture the
production screen including its navigation, tab, sheet, search, and toolbar
chrome when measurement converges. An intentionally bounded or greedy
container instead snapshots its shared scrolling child directly; never add
snapshot-only production layout. Fixed device frames are for non-scrolling
subjects.

## Testing

Expand Down
35 changes: 25 additions & 10 deletions Shared/SnapshotKit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

SnapshotKit is the generic, shippable half of a small snapshot-testing
framework. It owns the *appearance matrix* that drives both SwiftUI previews and
image snapshot tests, so what you see in an Xcode Preview is exactly what CI
asserts against.
image snapshot tests, so previews and CI share configurations, traits, and
content.

It deliberately imports **only** SwiftUI / Foundation / UIKit — never the
snapshot-comparison engine — so any UI module can depend on it (including in
Expand All @@ -21,16 +21,28 @@ capture + comparison pipeline lives in the sibling
Frames come in three sizing strategies: fixed device viewports (`.iPhone`,
`.iPad`), the intrinsic `.component` frame, and `.fullContent(name:width:)` —
fixed width, height measured from the settled content, so the whole
scrollable content renders in one image with nothing scrolling. Wrap
*content*, not chrome: a greedy container with pinned chrome
(`NavigationStack`) has no content-derived ideal height and collapses the
measurement to just that chrome. A frame also carries `safeAreaInsets`
(default zero, keeping images device-independent); the `.iPhoneNotched`
preset simulates real device chrome (Dynamic Island top 47pt, home-indicator
bottom 34pt) for cases that must prove layout under it.
scrollable content renders in one image with nothing scrolling. Full-width
scrolling descendants drive the measured height while preserving surrounding
navigation, tab, sheet, search, and toolbar chrome. An intentionally bounded
or greedy production container that cannot converge should expose and
snapshot its shared scrolling child directly, without snapshot-only layout
behavior. The iPhone/iPad
full-content presets retain their normal viewport height as a minimum and
grow when content is taller; custom full-content frames shrink-wrap unless
given a minimum. A frame also carries `safeAreaInsets` (default zero, keeping
images device-independent); the `.iPhoneNotched` preset simulates real device
chrome (Dynamic Island top 47pt, home-indicator bottom 34pt) for cases that
must prove layout under it.
- **`combinations(...)` + presets** (`.componentDefaults`, `.screenDefaults`,
`.fullContentScreenDefaults`) — expand a terse declaration into the full
matrix.
- **Full-content frames** (`.iPhoneFullContent`, `.iPadFullContent`, and
`.fullContent(name:width:)`) — capture the settled intrinsic height of
scrolling content, including UIKit-backed SwiftUI `List` and `Form`
containers, including when they are nested under production screen chrome.
Device presets render at least one normal viewport tall, then expand to show
content that would otherwise scroll; fixed-height device frames are for
non-scrolling subjects.
- **`SnapshotProviding`** — a type declares its variants via
`static var snapshots: [SnapshotCase]`.
- **`SnapshotCase`** — a named group of configurations plus a lazy content
Expand Down Expand Up @@ -105,7 +117,10 @@ assertSnapshots(of: MyBadge.self)

- Accessibility (`.accessibility`) configurations are **filtered out of the
preview cutsheet** — VoiceOver-annotated captures need the test-only library
and can't render in a plain Preview. They still run as snapshot tests.
and can't render in a plain Preview. They still run as snapshot tests. The
cutsheet also cannot reproduce the capture pipeline's UIKit-backed
`List`/`Form` height measurement, safe-area override, ready hook, or
tile-and-stitch pass, so CI's rendered dimensions remain authoritative.
- The Where app wraps content in its Broadway design-system root via a
`whereSnapshot(...)` adapter in `WhereUI`; SnapshotKit itself stays
design-system-agnostic.
25 changes: 16 additions & 9 deletions Shared/SnapshotKit/Sources/SnapshotCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ public enum SnapshotSettle: Equatable, Sendable {
/// render, and the content to render under each.
///
/// It is also a `View`, so it renders a labeled, scrollable cutsheet of its
/// variants inside a `#Preview` — the same content the snapshot tests capture.
/// variants inside a `#Preview` — the same matrix, traits, and content the
/// snapshot tests capture. Test-only capture mechanics are documented on
/// ``previewConfigurations``.
/// Accessibility variants are excluded from that preview (see
/// ``previewConfigurations``); they only render as tests.
public struct SnapshotCase: Identifiable {
Expand Down Expand Up @@ -71,7 +73,11 @@ public struct SnapshotCase: Identifiable {

/// The configurations that can render in a plain SwiftUI preview. Accessibility
/// captures need the test-only library's VoiceOver parser, so they're dropped
/// from the cutsheet (they still run as snapshot tests).
/// from the cutsheet (they still run as snapshot tests). The cutsheet also
/// cannot perform the capture pipeline's UIKit-backed `List`/`Form`
/// measurement, safe-area override, async ready hook, or tile-and-stitch;
/// full-content preview height is therefore an approximation while the test
/// capture is authoritative.
public var previewConfigurations: [SnapshotConfiguration] {
configurations.filter { $0.snapshotType != .accessibility }
}
Expand All @@ -87,8 +93,8 @@ extension SnapshotCase: View {
.foregroundStyle(.secondary)
framed(for: configuration)
.snapshotTraits(configuration)
// Previews mirror what the tests capture, so each
// variant renders its deterministic capture state.
// Preview the same deterministic content state as tests;
// capture-only mechanics remain test-pipeline concerns.
.environment(\.isCapturingSnapshot, true)
}
}
Expand All @@ -110,11 +116,12 @@ extension SnapshotCase: View {
}
case let .fixed(size):
content.frame(width: size.width, height: size.height)
case let .fullContent(width):
// No height: in the cutsheet's scroll view the content gets an
// unbounded proposal and takes its ideal (content) height, the
// preview analogue of the pipeline's content measurement.
content.frame(width: width)
case let .fullContent(width, minimumHeight):
// The cutsheet shares the viewport minimum but cannot run the
// test pipeline's UIKit descendant measurement.
content
.frame(width: width)
.frame(minHeight: minimumHeight)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ extension [SnapshotConfiguration] {
defaults(devices: [.iPhone, .iPad])
}

/// The default full-screen trait matrix at iPhone and iPad widths, with each
/// frame measured to the settled content's intrinsic height.
/// The default full-screen trait matrix at iPhone and iPad viewport sizes,
/// with each frame growing beyond its device height when settled content is
/// taller.
public static var fullContentScreenDefaults: Self {
defaults(devices: [.iPhoneFullContent, .iPadFullContent])
}
Expand Down
52 changes: 35 additions & 17 deletions Shared/SnapshotKit/Sources/SnapshotConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ extension SnapshotConfiguration {
/// size in identifiers (e.g. `iPhone` instead of `402x874`).
public struct Frame: Hashable, Sendable {
private static let iPhoneWidth: CGFloat = 402
private static let iPhoneHeight: CGFloat = 874
private static let iPadWidth: CGFloat = 834
private static let iPadHeight: CGFloat = 1194

/// The identifier token for this frame (`""` for the unnamed component
/// frame, `iPhone`/`iPad` for device frames).
Expand All @@ -97,7 +99,11 @@ extension SnapshotConfiguration {
/// notch/home-indicator insets.
public var safeAreaInsets: Insets

public init(name: String, size: SizeStrategy, safeAreaInsets: Insets = .zero) {
public init(
name: String,
size: SizeStrategy,
safeAreaInsets: Insets = .zero,
) {
self.name = name
self.size = size
self.safeAreaInsets = safeAreaInsets
Expand Down Expand Up @@ -129,41 +135,53 @@ extension SnapshotConfiguration {
/// scrolls. A `ScrollView` measured this way reports its content height,
/// so wrapping scrollable content captures every row (lazy stacks
/// materialize fully — at full-content height every row is visible).
///
/// **Wrap content, not chrome.** Greedy containers with pinned chrome
/// (`NavigationStack`, a sheet with a pinned picker) have no
/// content-derived ideal height and collapse the measurement to just
/// that chrome — pass the scrollable content itself, without the
/// navigation wrapper.
/// Production navigation, tab, sheet, search, and toolbar chrome stays
/// wrapped around the full-width scrolling descendant that drives the
/// measured height.
///
/// `name` is the identifier token for the frame (conventionally
/// `fullHeight`); it must stay stable once references are recorded.
public static func fullContent(name: String, width: CGFloat) -> Frame {
Frame(name: name, size: .fullContent(width: width))
public static func fullContent(
name: String,
width: CGFloat,
minimumHeight: CGFloat? = nil,
) -> Frame {
Frame(
name: name,
size: .fullContent(width: width, minimumHeight: minimumHeight),
)
}

/// The iPhone frame width with height measured from the settled content.
public static let iPhoneFullContent = fullContent(name: "iPhone", width: iPhoneWidth)
/// An iPhone viewport that grows to fit settled scrolling content.
public static let iPhoneFullContent = fullContent(
name: "iPhone",
width: iPhoneWidth,
minimumHeight: iPhoneHeight,
)

/// The iPad frame width with height measured from the settled content.
public static let iPadFullContent = fullContent(name: "iPad", width: iPadWidth)
/// An iPad viewport that grows to fit settled scrolling content.
public static let iPadFullContent = fullContent(
name: "iPad",
width: iPadWidth,
minimumHeight: iPadHeight,
)

/// A phone screen frame (iPhone 17 point size).
public static let iPhone = Frame(
name: "iPhone",
size: .fixed(CGSize(width: iPhoneWidth, height: 874)),
size: .fixed(CGSize(width: iPhoneWidth, height: iPhoneHeight)),
)
/// A tablet screen frame (iPad Pro 11" portrait point size).
public static let iPad = Frame(
name: "iPad",
size: .fixed(CGSize(width: iPadWidth, height: 1194)),
size: .fixed(CGSize(width: iPadWidth, height: iPadHeight)),
)
/// The iPhone frame with simulated device insets (Dynamic Island top,
/// home-indicator bottom), for cases that must prove layout under real
/// device chrome rather than the inset-free default.
public static let iPhoneNotched = Frame(
name: "iPhoneNotched",
size: .fixed(CGSize(width: iPhoneWidth, height: 874)),
size: .fixed(CGSize(width: iPhoneWidth, height: iPhoneHeight)),
safeAreaInsets: Insets(top: 47, leading: 0, bottom: 34, trailing: 0),
)
}
Expand All @@ -176,7 +194,7 @@ extension SnapshotConfiguration {
case fixed(CGSize)
/// A fixed width with the height measured from the settled content, so
/// scrollable content renders whole (see ``Frame/fullContent(name:width:)``).
case fullContent(width: CGFloat)
case fullContent(width: CGFloat, minimumHeight: CGFloat?)
}
}

Expand Down
Loading
Loading