diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15f00bdf..79368aa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,8 +146,12 @@ jobs: udid="$(./simulator --shared --os 27.0)" echo "udid=$udid" >> "$GITHUB_OUTPUT" # Image snapshots are slow and LFS-backed, so they run in their own job via - # the standalone `WhereUISnapshotTests` scheme (declared in Project.swift) + # the standalone `StuffSnapshotTests` scheme (declared in Project.swift) # rather than the aggregate `Stuff-iOS-Tests` scheme the `test` job uses. + # That one scheme covers every module's image suite: they all compile into + # a single bundle, deliberately — see the `StuffSnapshotTests` comment in + # Project.swift for why a second snapshot bundle in this process would + # break the capture pipeline's process-global state. # # Deliberately NOT `-parallel-testing-enabled`: the capture pipeline is # single-tenant — every test renders into the one StuffTestHost key @@ -158,7 +162,7 @@ jobs: # that one host process, produced 24+ spurious image mismatches, and # was ~1.2–3x SLOWER than the serial run (407s/1004s vs ~330s). - name: Snapshot Tests (iOS) - run: mise exec -- tuist test WhereUISnapshotTests --no-selective-testing -- -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" + run: mise exec -- tuist test StuffSnapshotTests --no-selective-testing -- -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" - name: Collect snapshot diagnostics if: ${{ failure() }} run: | diff --git a/AGENTS.md b/AGENTS.md index 09896127..8a233873 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,7 +135,8 @@ that nothing in this repo depends on a skill having been loaded. `AGENTS.md` says what it is and how it may be used. - Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). - **CI scheme**: CI runs the explicit shared **Stuff-iOS-Tests** scheme (all test bundles) rather than the autogenerated `Stuff-Workspace` scheme. New test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them. -- **Snapshot test bundles are the exception**: image snapshot bundles (currently **WhereUISnapshotTests**) are slow and LFS-backed, so each runs via its own standalone scheme (declared in `Project.swift`, e.g. `WhereUISnapshotTests`) and a dedicated CI `snapshot` job — deliberately **not** in `Stuff-iOS-Tests`. Reference images under any `__Snapshots__/` directory are stored in **Git LFS** (see `.gitattributes`); the CI job checks out with `lfs: true`. The framework halves live in `Shared/SnapshotKit` (shippable matrix + previews) and `Shared/SnapshotKitTesting` (test-only capture/compare pipeline). The pipeline's own regression tests are a *separate* bundle, **SnapshotKitTestingTests**, which pixel-probes captures (no LFS references) and so runs in the normal `Stuff-iOS-Tests` scheme / `test` job — only the image-reference bundles above take the standalone-scheme + `snapshot`-job route. +- **Image snapshots are the exception, and they all live in one bundle**: **StuffSnapshotTests** is slow and LFS-backed, so it runs via its own standalone scheme (declared in `Project.swift`) and a dedicated CI `snapshot` job — deliberately **not** in `Stuff-iOS-Tests`. Reference images under any `__Snapshots__/` directory are stored in **Git LFS** (see `.gitattributes`); the CI job checks out with `lfs: true`. The framework halves live in `Shared/SnapshotKit` (shippable matrix + previews) and `Shared/SnapshotKitTesting` (test-only capture/compare pipeline). The pipeline's own regression tests are a *separate* bundle, **SnapshotKitTestingTests**, which pixel-probes captures (no LFS references) and so runs in the normal `Stuff-iOS-Tests` scheme / `test` job — only the image-reference bundle takes the standalone-scheme + `snapshot`-job route. +- **Never add a second image-snapshot bundle.** A new module's image suite joins `StuffSnapshotTests` by adding its `SnapshotTests/` directory to that target's `sources` — it does **not** get a bundle of its own. Every `.xctest` statically embeds what it links, so a second snapshot bundle means a second copy of `SnapshotKitTesting`, whose capture state is module-global and therefore per copy: the safe-area swizzle's depth counter and override globals, `SnapshotCaptureLock`, and the `UIView` category it installs. Two copies in one `StuffTestHost` process would count separate depths against the one shared method exchange (captures silently rendering with the simulator's real safe-area insets) and neither lock would see the other's captures. Ownership still lives with the module: suites go in `/SnapshotTests/` and record references beside themselves, because swift-snapshot-testing derives the `__Snapshots__` directory from the calling file's `#filePath`. Per-module ownership needs per-module *directories*, not per-module bundles. - A UI module that opts into image snapshots adds a `SnapshotTests/` folder alongside the usual `Sources/`+`Tests/` skeleton, holding the bundle and its LFS-tracked `__Snapshots__/` references. ### Never double-link a product a dynamic framework already carries diff --git a/Project.swift b/Project.swift index ad338479..f4e30f9f 100644 --- a/Project.swift +++ b/Project.swift @@ -344,7 +344,7 @@ let project = Project( // The capture/compare pipeline's own regression tests. They render // through `renderSnapshotImage` (so they need the `StuffTestHost` key // window) but assert on probed pixels rather than LFS reference images, - // so — unlike `WhereUISnapshotTests` — this bundle is fast, has no + // so — unlike `StuffSnapshotTests` — this bundle is fast, has no // `__Snapshots__/`, and runs in the main `Stuff-iOS-Tests` scheme / // `test` CI job. `SnapshotKitTesting` embeds its dependency closure // (SnapshotKit, SnapshotTesting, AccessibilitySnapshot) into the @@ -405,32 +405,59 @@ let project = Project( productDependency: "WhereIntents", sources: ["Where/WhereIntents/Tests/**"], ), - // Image snapshot tests for WhereUI. Slow + LFS-backed, so this bundle - // runs in its own `snapshot` CI job (see .github/workflows/ci.yml) via - // its own `testScheme` below — it is deliberately NOT in the - // `Stuff-iOS-Tests` scheme, keeping image snapshots out of the main - // `test` job. Lists only `SnapshotKitTesting` in `extraPackageProducts`: - // the test-only capture pipeline, which WhereUI deliberately never - // links. + // Every image snapshot suite in the repo, in ONE bundle. Slow + + // LFS-backed, so it runs in its own `snapshot` CI job (see + // .github/workflows/ci.yml) via its own `testScheme` below — it is + // deliberately NOT in the `Stuff-iOS-Tests` scheme, keeping image + // snapshots out of the main `test` job. + // + // One bundle, not one per module, and that is load-bearing rather than + // convenience. Every `.xctest` statically embeds what it links, so a + // second snapshot bundle would carry a second copy of + // `SnapshotKitTesting` — and its "process-global" capture state is + // module-global, i.e. per copy: `_swizzleDepth` and the override + // globals in `SafeAreaInsetsSwizzling.swift`, `SnapshotCaptureLock`, + // and the `UIView.snapshotKitOverriddenSafeAreaInsets` category. Two + // copies loaded into one `StuffTestHost` process would each count + // their own swizzle depth against the one shared `UIView` method + // exchange (parity flips → captures silently rendering with the + // simulator's real safe-area insets), and neither copy's capture lock + // could see the other's captures. Verified with `nm` on the built + // bundles, which show a private `_swizzleDepth` per `.xctest`. Keeping + // one bundle keeps one copy, which is what makes those types' "process + // wide" docs true. Suites still live in — and record their references + // next to — the module they cover: swift-snapshot-testing derives the + // `__Snapshots__` directory from the calling file's `#filePath`, which + // `assertSnapshots` threads through, so per-module ownership needs + // per-module *source directories*, not per-module bundles. + // + // Lists only `SnapshotKitTesting` in `extraPackageProducts`: the + // test-only capture pipeline, which WhereUI deliberately never links. + // PeriscopeTools and SwiftDataInspector are NOT listed — they arrive + // transitively through WhereUI, and re-listing either would land the + // duplicate copy the root AGENTS.md "Targets" note warns about. Their + // suites import them anyway, exactly as WhereUITests imports WhereCore + // and RegionKit without listing them. // // `SnapshotKitTesting` embeds its dependency closure, SnapshotKit // included — which WhereUI carries too — but that does *not* land a // second copy here: the linker coalesces them. Verified on the built // bundle, which defines exactly one set of SnapshotKit symbols, the // same count as WhereUITests (a bundle that links no extra products at - // all). So `\.isCapturingSnapshot` has no copy boundary to cross, and - // the duplicate-metadata hazard in the root AGENTS.md "Targets" note - // doesn't apply to this pairing. `SnapshotCaptureFlagProbeTests` still - // pins the path end to end — the pipeline's `traitOverrides` write - // reaching a WhereUI-defined view's read — so a toolchain that stopped - // coalescing would fail loudly rather than silently returning defaults. - // Do NOT re-list any *other* WhereUI transitive here: same rule as - // WhereUITests above. + // all). So `\.isCapturingSnapshot` has no copy boundary to cross. + // `SnapshotCaptureFlagProbeTests` still pins the path end to end — the + // pipeline's `traitOverrides` write reaching a WhereUI-defined view's + // read — so a toolchain that stopped coalescing would fail loudly + // rather than silently returning defaults. unitTests( - name: "WhereUISnapshotTests", - bundleIdSuffix: "whereui.snapshot", + name: "StuffSnapshotTests", + bundleIdSuffix: "snapshot", productDependency: "WhereUI", - sources: ["Where/WhereUI/SnapshotTests/**"], + sources: [ + "Where/WhereUI/SnapshotTests/**", + "Shared/Periscope/PeriscopeTools/SnapshotTests/**", + "Shared/SwiftDataInspector/SnapshotTests/**", + ], extraPackageProducts: ["SnapshotKitTesting"], ), .target( @@ -568,7 +595,7 @@ let project = Project( // date-rendering snapshot. SNAPSHOT_EXPECTED_TIMEZONE is the guard // that verifies the TZ pin actually reached the test process. testScheme( - name: "WhereUISnapshotTests", + name: "StuffSnapshotTests", testEnvironmentVariables: [ "SNAPSHOT_EXPECTED_SIMULATOR_RUNTIME_VERSION": "27.0", "SNAPSHOT_EXPECTED_SCREEN_SCALE": "3", diff --git a/Shared/Periscope/PeriscopeTools/AGENTS.md b/Shared/Periscope/PeriscopeTools/AGENTS.md index 8e46c55f..077a6b16 100644 --- a/Shared/Periscope/PeriscopeTools/AGENTS.md +++ b/Shared/Periscope/PeriscopeTools/AGENTS.md @@ -90,3 +90,19 @@ use `PeriscopeStylesheet.default`. Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` (`PeriscopeToolsTests`). Seed an in-memory store, drive the view models directly, and host views with `TestHostSupport`'s `show()` helpers. + +How the tools *look* is pinned separately by the image snapshots in +[`SnapshotTests/`](SnapshotTests), with references under +`SnapshotTests/__Snapshots__/` in Git LFS. Those files compile into the +repo-wide `StuffSnapshotTests` bundle rather than `PeriscopeToolsTests` — there +is exactly one image-snapshot bundle in the repo, for reasons the root +[`AGENTS.md`](../../../AGENTS.md#targets) explains — but they live and record +here, so this module owns its own reference images. Seed a **frozen** store +(fixed records at timestamps pinned to a fixed instant); the process-global +Periscope store's wall-clock timestamps would churn the images. Tool views seed +their own `periscopeBroadwayRoot()`, so a snapshot needs no host-app root and +captures the tooling's real styling. + +Prefer a snapshot over a "hosts without crashing" test: a hosting test that only +asserts the view reached a window says nothing about what rendered. Converting +the existing ones is tracked in [`TODOs.md`](../TODOs.md). diff --git a/Shared/Periscope/PeriscopeTools/README.md b/Shared/Periscope/PeriscopeTools/README.md index feef7188..1f7a692d 100644 --- a/Shared/Periscope/PeriscopeTools/README.md +++ b/Shared/Periscope/PeriscopeTools/README.md @@ -109,3 +109,11 @@ Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` (`PeriscopeToolsTests` bundle): models are driven directly over in-memory stores (`@_spi(Testing) PeriscopeStore.inMemory`), views host via `TestHostSupport.show`. Run with `tuist test PeriscopeToolsTests`. + +How the tools render is pinned by image snapshots in +[`SnapshotTests/`](SnapshotTests), with reference images under +`SnapshotTests/__Snapshots__/` in Git LFS. They compile into the repo-wide +`StuffSnapshotTests` bundle (one snapshot bundle for the whole repo) but record +their references here. Run with `tuist test StuffSnapshotTests`; to re-record +after an intentional UI change, see the +[SnapshotKitTesting README](../../SnapshotKitTesting/README.md#recording). diff --git a/Where/WhereUI/SnapshotTests/PeriscopeViewerSnapshotTests.swift b/Shared/Periscope/PeriscopeTools/SnapshotTests/PeriscopeViewerSnapshotTests.swift similarity index 68% rename from Where/WhereUI/SnapshotTests/PeriscopeViewerSnapshotTests.swift rename to Shared/Periscope/PeriscopeTools/SnapshotTests/PeriscopeViewerSnapshotTests.swift index 6cfad299..2ec93973 100644 --- a/Where/WhereUI/SnapshotTests/PeriscopeViewerSnapshotTests.swift +++ b/Shared/Periscope/PeriscopeTools/SnapshotTests/PeriscopeViewerSnapshotTests.swift @@ -1,14 +1,13 @@ +import Foundation @_spi(Testing) import PeriscopeCore import PeriscopeTools import SnapshotKitTesting import SwiftUI import Testing -@testable import WhereUI -/// Pins the log viewer the developer tools push. It lives in PeriscopeTools -/// rather than WhereUI, but it's captured here because this is where it's -/// reachable in the app, and the capture goes through Where's Broadway root so -/// it renders with app styling. +/// Pins how the log viewer renders. `PeriscopeViewer` seeds its own +/// `periscopeBroadwayRoot()`, so this captures the tooling's real styling with +/// no host-app root involved — the module owns its own appearance here. @MainActor struct PeriscopeViewerSnapshotTests { @Test func periscopeViewer() async throws { @@ -16,7 +15,6 @@ struct PeriscopeViewerSnapshotTests { let viewer = NavigationStack { PeriscopeViewer(store: store, title: "Logs") } - .whereBroadwayRoot() await assertSnapshots( of: viewer, named: "PeriscopeViewer", @@ -27,14 +25,23 @@ struct PeriscopeViewerSnapshotTests { ) } + /// Fixed "now" the fixture's timestamps hang off, so the rendered log times + /// don't churn the references every real-world day. Pacific to match the + /// `TZ` pin the snapshot scheme sets — see `Project.swift`. + private static let referenceNow: Date = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return calendar.date(from: DateComponents(year: 2026, month: 7, day: 15, hour: 12))! + }() + /// A frozen store — fixed records at timestamps pinned around - /// `PreviewSupport.referenceNow` — rather than the process-global Periscope - /// store, whose wall-clock timestamps and run-dependent lines would make the - /// image nondeterministic. + /// ``referenceNow`` — rather than the process-global Periscope store, whose + /// wall-clock timestamps and run-dependent lines would make the image + /// nondeterministic. private static func frozenStore() async throws -> PeriscopeStore { let session = LogSession( id: UUID(uuidString: "00000000-0000-0000-0000-0000000000FF")!, - startedAt: PreviewSupport.referenceNow.addingTimeInterval(-600), + startedAt: referenceNow.addingTimeInterval(-600), appVersion: "1.0", buildNumber: "1", osVersion: "iOS 26.0", @@ -60,7 +67,7 @@ struct PeriscopeViewerSnapshotTests { ] await store.write(lines.map { line in LogRecord( - date: PreviewSupport.referenceNow.addingTimeInterval(-line.age), + date: referenceNow.addingTimeInterval(-line.age), event: Message(level: line.level, line.text), scopes: [line.scope.id], tags: [], diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone.png b/Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone.png similarity index 100% rename from Where/WhereUI/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone.png rename to Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone.png diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone_dark.png b/Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone_dark.png similarity index 100% rename from Where/WhereUI/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone_dark.png rename to Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone_dark.png diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 6a125a9f..45e936da 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -41,7 +41,7 @@ here. - feat(PeriscopeTools) [needs-design]: Inspect-by-object is scope-granular, not instance-granular. `.logInspectable(_:)` keys the badge/inspector to a `Log`'s *scope*, so tagging a list row (Where tags `EvidenceRow` with `WhereLog.evidence`, `LocationStatusRow` with `WhereLog.session`) surfaces the whole scope's recent events, not that one row's. Events already carry `externalID` for object correlation, but the inspector can't filter by it — a per-instance child scope (blocked on the `LogContextProviding` parent-hierarchy P0) or an `externalID`-scoped inspect entry would make true row-/object-level inspection work. (pr#94 review) - design(PeriscopeCore) [needs-design]: No eager store handle — `PeriscopeStore.make` being `async` forces an "optional store, observe until it lands" dance on consumers. Where exposes an `Optional` on `WhereModel` that stays `nil` until the bootstrap `Task` completes, and `RootView` has to watch the transition (`.onChange` of the store identity) to wire the viewer/inspector/alerter. A synchronous pending-store handle (usable immediately, resolves in the background) or an `await`-readiness accessor would remove the optional-and-observe boilerplate every app repeats. (agent) - test(PeriscopeTools) [quick-win]: Add the missing 1:1 tests for the small `*+Display` extensions. (audit 2026-07-26) -- test(PeriscopeTools) [needs-design]: broken-snapshots — replace the hosting smoke tests with image snapshots. Eighteen tests across nine files assert nothing but "the hosted view reached a window": `#expect(await waitUntil { host.view.window != nil })` in `LogEventListTests.swift:30`, `:41`, `LogHierarchyViewHostingTests.swift:23`, `:34`, `PeriscopeViewerHostingTests.swift:29`, `:42`, `ScopeEventsViewHostingTests.swift:25`, `:38`, `SpanHistoryViewHostingTests.swift:23`, `:34`, `SpanTreeViewHostingTests.swift:26`, `:37`, and the `try waitFor { host.view.window != nil }` spelling in `LogInspectableHostingTests.swift:25`, `:38`, `:50`, `LogTraceViewHostingTests.swift:23`, `OpenSpansViewHostingTests.swift:27`, `:38`. The predicate restates what `show`/`showHosted` already guarantee, so each test proves only that construction didn't crash — never what rendered, which is the part the elaborate seeding sets up (`LogHierarchyView`'s outline, the comfortable density `PeriscopeViewerHostingTests` injects, the "No Events" state `ScopeEventsViewHostingTests` documents at `:29`). The repo convention is now that an image bundle, not a hosting smoke test, owns "does this screen render" (see [`Where/WhereUI/AGENTS.md`](../../Where/WhereUI/AGENTS.md#testing) and the WhereUI suite that replaced its own smoke tests). Convert them to a `PeriscopeToolsSnapshotTests` bundle over the same seeded stores, keeping any assertion that isn't the window check and deleting the files left empty. The bundle is the prerequisite tracked in the root [`TODOs.md`](../../TODOs.md) broken-snapshots item, which also wants `PeriscopeViewer`'s snapshot moved out of WhereUI — land them together. (Note the two `window != nil` checks in `Shared/LifecycleKit/Tests/` are *not* in scope: they assert the hosting helper's own lifecycle contract, which is the one place the check is the point.) (pr#101 review) +- test(PeriscopeTools) [needs-design]: broken-snapshots — replace the hosting smoke tests with image snapshots. Eighteen tests across nine files assert nothing but "the hosted view reached a window": `#expect(await waitUntil { host.view.window != nil })` in `LogEventListTests.swift:30`, `:41`, `LogHierarchyViewHostingTests.swift:23`, `:34`, `PeriscopeViewerHostingTests.swift:29`, `:42`, `ScopeEventsViewHostingTests.swift:25`, `:38`, `SpanHistoryViewHostingTests.swift:23`, `:34`, `SpanTreeViewHostingTests.swift:26`, `:37`, and the `try waitFor { host.view.window != nil }` spelling in `LogInspectableHostingTests.swift:25`, `:38`, `:50`, `LogTraceViewHostingTests.swift:23`, `OpenSpansViewHostingTests.swift:27`, `:38`. The predicate restates what `show`/`showHosted` already guarantee, so each test proves only that construction didn't crash — never what rendered, which is the part the elaborate seeding sets up (`LogHierarchyView`'s outline, the comfortable density `PeriscopeViewerHostingTests` injects, the "No Events" state `ScopeEventsViewHostingTests` documents at `:29`). The repo convention is now that an image bundle, not a hosting smoke test, owns "does this screen render" (see [`Where/WhereUI/AGENTS.md`](../../Where/WhereUI/AGENTS.md#testing) and the WhereUI suite that replaced its own smoke tests). Convert them to image snapshots over the same seeded stores, keeping any assertion that isn't the window check and deleting the files left empty. **The plumbing is already in place**: [`SnapshotTests/`](PeriscopeTools/SnapshotTests) exists and `PeriscopeViewerSnapshotTests` is the worked example — add a file per view beside it, and it compiles into the repo-wide `StuffSnapshotTests` bundle while recording references here. Do *not* add a `PeriscopeToolsSnapshotTests` bundle; there is exactly one image-snapshot bundle by design (root [`AGENTS.md`](../../AGENTS.md#targets)). The remaining work is per-view authoring, not wiring: each view needs a deterministic fixture (a frozen store, as `PeriscopeViewerSnapshotTests` does) and ideally a `SnapshotProviding` conformance in its own source file — which needs a `SnapshotKit` dependency on PeriscopeTools, since the module has no `#Preview`s at all today. `OpenSpansView` is the one view with a genuine determinism problem: its `TimelineView(.periodic(from: .now, by: 1))` ticking ages (`OpenSpansView.swift:19`) need the `\.isCapturingSnapshot` treatment. (Note the two `window != nil` checks in `Shared/LifecycleKit/Tests/` are *not* in scope: they assert the hosting helper's own lifecycle contract, which is the one place the check is the point.) (pr#101 review) # Completed issues diff --git a/Shared/SnapshotKitTesting/AGENTS.md b/Shared/SnapshotKitTesting/AGENTS.md index 6fe9bfc8..a187f747 100644 --- a/Shared/SnapshotKitTesting/AGENTS.md +++ b/Shared/SnapshotKitTesting/AGENTS.md @@ -10,9 +10,24 @@ Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first. - Depends on `SnapshotKit`, `TestHostSupport`, `SnapshotTesting` (swift-snapshot-testing), and `AccessibilitySnapshot` (cashapp). It links the - comparison engine + XCTest/Testing, so it is **only** consumed by - `*SnapshotTests` bundles via `extraPackageProducts` — **never** a shipping app - or `StuffTestHost`. + comparison engine + XCTest/Testing, so it is **only** consumed by the + `StuffSnapshotTests` and `SnapshotKitTestingTests` bundles via + `extraPackageProducts` — **never** a shipping app or `StuffTestHost`. +- **Exactly one image-snapshot bundle may exist, and this module is why.** + Everything below described as "process-global" — the safe-area swizzle's + depth counter and override globals, `SnapshotCaptureLock`, the animations + save/restore — is *module*-global, so it is per copy of this module. Each + `.xctest` statically embeds what it links, so two snapshot bundles co-loaded + into one `StuffTestHost` process would hold two independent copies of that + state while sharing one `UIView` method exchange: the depth counters flip + parity against each other (captures silently render with the simulator's + real safe-area insets) and neither capture lock can see the other's + captures. Verified with `nm` on the built bundles — each defines its own + private `_swizzleDepth`. Hence one bundle, `StuffSnapshotTests`; a new + module's image suite joins it as another `sources` directory (root + [`AGENTS.md`](../../AGENTS.md#targets)). `SnapshotKitTestingTests` is the + sanctioned second consumer only because it runs in a *different* scheme, so + the two never share a process. - **The consuming bundle double-embeds `SnapshotKit`, tolerated and guarded.** Listing this product in `extraPackageProducts` statically embeds its dependency closure — including `SnapshotKit` — into the `.xctest`, while a @@ -22,8 +37,8 @@ Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first. risk. There is no cleaner wiring (the pipeline must reach the bundle without ever linking into the UI framework), the trait lookup demonstrably resolves across both copies today, and - `WhereUISnapshotTests.SnapshotCaptureFlagProbeTests` fails loudly if the - copies ever split — see the WhereUISnapshotTests comment in + `StuffSnapshotTests.SnapshotCaptureFlagProbeTests` fails loudly if the + copies ever split — see the StuffSnapshotTests comment in `Project.swift` for the full topology. - Re-exports `SnapshotKit` and `SnapshotTesting` so consumers need one import. - Library target in [`Package.swift`](../../Package.swift). @@ -61,7 +76,7 @@ Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first. `renderSnapshotImage` serializes captures through a FIFO `@MainActor` mutex, and the safe-area swizzle is depth-counted so unbalanced pairs can't flip the method-exchange parity (guarded by - `WhereUISnapshotTests.ConcurrentCaptureTests`). Nested captures — a hook + `StuffSnapshotTests.ConcurrentCaptureTests`). Nested captures — a hook rendering another snapshot — trap. Keep the suite serial anyway: concurrent scheduling now degrades to queued-serial rather than corrupt, gaining nothing. - **Rendering runs in the host key window.** It requires `StuffTestHost`'s window @@ -78,7 +93,7 @@ Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first. - **Tile-and-stitch is load-bearing, not legacy.** UIKit still renders a blank image for views taller/wider than ~2000pt on the target toolchain (iOS 27.0 — verified by a probe during development, guarded by - `WhereUISnapshotTests.LargeViewCaptureTests`). Captures go through + `StuffSnapshotTests.LargeViewCaptureTests`). Captures go through `SnapshotWrappingViewController` + `tileAndStitchImage`; don't remove the tiling on the assumption the bug is fixed without re-running that check. @@ -94,7 +109,7 @@ root while an interior `safeAreaInset` still composes). They render through probed pixels via the `@_spi(Testing)` `PixelSample`/`probePixel` API rather than LFS reference images — so the bundle is fast, has no `__Snapshots__/`, and runs in the main `test` job, not the snapshot job. The matrixed image assertions -themselves are still exercised by consumer bundles (currently -`WhereUISnapshotTests`); the WhereUI↔bundle cross-boundary flag probe stays there +themselves are still exercised by the consumer bundle +(`StuffSnapshotTests`); the WhereUI↔bundle cross-boundary flag probe stays there (`SnapshotCaptureFlagProbeTests`), since only a WhereUI-defined view can detect a duplicate-`SnapshotKit` split. diff --git a/Shared/SnapshotKitTesting/README.md b/Shared/SnapshotKitTesting/README.md index f6c0b924..729ebbd3 100644 --- a/Shared/SnapshotKitTesting/README.md +++ b/Shared/SnapshotKitTesting/README.md @@ -83,7 +83,7 @@ xcodebuild passes any `TEST_RUNNER_`-prefixed variable through, so in this repo (verified working): ```bash -TEST_RUNNER_SNAPSHOT_RECORD=failed mise exec -- tuist test WhereUISnapshotTests \ +TEST_RUNNER_SNAPSHOT_RECORD=failed mise exec -- tuist test StuffSnapshotTests \ --no-selective-testing -- \ -destination "platform=iOS Simulator,id=$(./simulator --os 27.0)" ``` diff --git a/Shared/SwiftDataInspector/AGENTS.md b/Shared/SwiftDataInspector/AGENTS.md index 2b98930a..e70d397a 100644 --- a/Shared/SwiftDataInspector/AGENTS.md +++ b/Shared/SwiftDataInspector/AGENTS.md @@ -50,3 +50,15 @@ Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost`: build an in-memory `ModelContainer` with local `@Model` fixtures, drive `SwiftDataInspectorModel`, and assert on the returned snapshots. Real-app wiring is covered from the consumer side (`WhereUITests`). + +How it renders is pinned by the image snapshots in +[`SnapshotTests/`](SnapshotTests), which compile into the repo-wide +`StuffSnapshotTests` bundle but record their references here — there is exactly +one image-snapshot bundle in the repo, for reasons the root +[`AGENTS.md`](../../AGENTS.md#targets) explains. Snapshot the module the way a +consumer gets it: a **local fixture schema**, never a host app's store, and no +design-system root. Prefer adding a case there over a hosted "renders without +crashing" test, which asserts nothing about the result. Fixture `@Model` names +must not collide with the ones in [`Tests/`](Tests) — those are a separate +bundle, and duplicate model class names would clash in the runtime if both ever +loaded into one host process. diff --git a/Shared/SwiftDataInspector/README.md b/Shared/SwiftDataInspector/README.md index 540d8163..5c287d37 100644 --- a/Shared/SwiftDataInspector/README.md +++ b/Shared/SwiftDataInspector/README.md @@ -200,3 +200,13 @@ rows), pagination (a growing prefix that covers every row with stable, distinct `persistentID`s and flips `isTruncated`), and relationship resolution (to-many, to-many capped to `rowLimit` with the true `totalCount`, to-one, empty/nil, and a stale source id that degrades instead of trapping). + +How the inspector *renders* is pinned by image snapshots in +[`SnapshotTests/`](SnapshotTests), with reference images under +`SnapshotTests/__Snapshots__/` in Git LFS. They build the same kind of local +in-memory fixture schema and apply no design-system root, so the capture shows +what a consumer actually gets rather than any one host app's styling. The files +compile into the repo-wide `StuffSnapshotTests` bundle (one snapshot bundle for +the whole repo) but record their references here. Run with `tuist test +StuffSnapshotTests`; to re-record after an intentional UI change, see the +[SnapshotKitTesting README](../SnapshotKitTesting/README.md#recording). diff --git a/Shared/SwiftDataInspector/SnapshotTests/SwiftDataInspectorSnapshotTests.swift b/Shared/SwiftDataInspector/SnapshotTests/SwiftDataInspectorSnapshotTests.swift new file mode 100644 index 00000000..cef24b92 --- /dev/null +++ b/Shared/SwiftDataInspector/SnapshotTests/SwiftDataInspectorSnapshotTests.swift @@ -0,0 +1,101 @@ +import Foundation +import SnapshotKitTesting +import SwiftData +import SwiftDataInspector +import SwiftUI +import Testing + +/// Pins how the root entity list renders, over a store built from this +/// module's own fixtures. +/// +/// The inspector is app-agnostic, so it is snapshotted that way: a local +/// in-memory schema rather than a host app's. It seeds a fixed row count per +/// entity because the list renders those counts, and it applies no design-system +/// root — the module has none, and the capture should show what a consumer +/// actually gets. +@MainActor +struct SwiftDataInspectorSnapshotTests { + @Test func swiftDataInspector() async throws { + let container = try Self.seededContainer() + let view = NavigationStack { + SwiftDataInspectorView( + configuration: SwiftDataInspectorConfiguration( + container: container, + modelTypes: nil, + title: "SwiftData", + rowLimit: 500, + valueFormatter: nil, + ), + ) + } + await assertSnapshots( + of: view, + named: "SwiftDataInspector", + configurations: SnapshotConfiguration.combinations( + devices: [.iPhone], + colorSchemes: [.light, .dark], + ), + ) + } + + /// An in-memory store with a fixed number of rows per entity, so the row + /// counts the list renders are the same on every run. + private static func seededContainer() throws -> ModelContainer { + let schema = Schema([SnapshotAlbum.self, SnapshotTrack.self, SnapshotPlaylist.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [configuration]) + + let context = container.mainContext + for index in 0 ..< 3 { + context.insert(SnapshotAlbum( + title: "Album \(index)", + releasedAt: Date(timeIntervalSince1970: TimeInterval(index)), + trackCount: index + 4, + )) + } + for index in 0 ..< 12 { + context.insert(SnapshotTrack(title: "Track \(index)", durationSeconds: 120 + index)) + } + context.insert(SnapshotPlaylist(name: "Favourites")) + try context.save() + return container + } +} + +// Fixture models. Named distinctly from `SwiftDataInspectorTests`' `Test*` +// models: those live in a different bundle, and two `@Model` classes sharing a +// name would collide in the runtime if both bundles ever loaded into one host +// process. + +@Model +final class SnapshotAlbum { + var title: String? + var releasedAt: Date? + var trackCount: Int? + + init(title: String?, releasedAt: Date?, trackCount: Int?) { + self.title = title + self.releasedAt = releasedAt + self.trackCount = trackCount + } +} + +@Model +final class SnapshotTrack { + var title: String? + var durationSeconds: Int? + + init(title: String?, durationSeconds: Int?) { + self.title = title + self.durationSeconds = durationSeconds + } +} + +@Model +final class SnapshotPlaylist { + var name: String? + + init(name: String?) { + self.name = name + } +} diff --git a/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png b/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png new file mode 100644 index 00000000..a7b9f6e1 --- /dev/null +++ b/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c94a58ffbf68c715e476ff057ea433419de94d9837d33062f99425d12e6c3a4a +size 164339 diff --git a/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone_dark.png b/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone_dark.png new file mode 100644 index 00000000..7293ca28 --- /dev/null +++ b/Shared/SwiftDataInspector/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b71dfe41d139346d24bcc69db165a606381fdbd8efb73506a3fc53dd05c08df7 +size 135642 diff --git a/Shared/SwiftDataInspector/TODOs.md b/Shared/SwiftDataInspector/TODOs.md index d799f1db..abd5f0bd 100644 --- a/Shared/SwiftDataInspector/TODOs.md +++ b/Shared/SwiftDataInspector/TODOs.md @@ -9,6 +9,6 @@ here. ## P1s (Should do) - test [quick-win]: The bare-`PersistentIdentifier` branch of relationship resolution (`SwiftDataReflection.swift:132`) is untested — the existing relationship test faults a materialized model, which takes the other path. Add a fixture test over an unmaterialized slot. (audit 2026-07-26) - test [needs-design]: `Tests/SwiftDataInspectorTests.swift` is one 759-line file covering 13 sources, against the 1:1 convention. Split it by concern, optionally adding hosted UI smoke tests. (audit 2026-07-26) - - test [needs-design]: broken-snapshots — don't add those hosting smoke tests; take image snapshots instead. The repo convention is now that a `*SnapshotTests` bundle owns "does this screen render", and a "hosts without crashing" test asserts nothing about the result (see the conversion item in [`Shared/Periscope/TODOs.md`](../Periscope/TODOs.md)). This module has no snapshot bundle yet — adding one is tracked in the root [`TODOs.md`](../../TODOs.md) broken-snapshots item, which also wants `SwiftDataInspectorSnapshotTests` moved here out of `WhereUI`. Do the split against that bundle rather than seeding smoke tests it would immediately replace. (pr#101 review) + - test [quick-win]: broken-snapshots — don't add those hosting smoke tests; add image snapshots instead. An image suite, not a "hosts without crashing" test, owns "does this screen render" — the latter asserts nothing about the result. [`SnapshotTests/`](SnapshotTests) already exists with `SwiftDataInspectorSnapshotTests` as the worked example (a local fixture schema, no design-system root); add a file per surface beside it. The root entity list is covered; the paged row table and the relationship drill-in are not. (pr#101 review) # Completed issues diff --git a/TODOs.md b/TODOs.md index 42500ac9..ff1f5199 100644 --- a/TODOs.md +++ b/TODOs.md @@ -98,9 +98,9 @@ inbox rather than here. ## P1s (Should do) ## P2s (Nice to have) -- test(WhereUI) [needs-design]: broken-snapshots — two `WhereUISnapshotTests` suites pin views WhereUI doesn't own, flagged `[Fix later]` on PR #101. Each belongs to the module that owns the view, so both span WhereUI and a shared module and sit here rather than in either file. Both are blocked on the same prerequisite: neither shared module has a snapshot bundle yet, and adding one means a `*SnapshotTests` target plus its own standalone scheme and a `snapshot` CI job, following the `WhereUISnapshotTests` pattern in `Project.swift` — image bundles are deliberately kept out of `Stuff-iOS-Tests`. (pr#101 review) - - test(PeriscopeTools) [needs-design]: Move the `PeriscopeViewer` snapshot into Periscope. `Where/WhereUI/SnapshotTests/PeriscopeViewerSnapshotTests.swift` captures a PeriscopeTools view, and its own doc comment (`:8`–`:11`) admits it lives in Where only because that's where the app reaches it; it already builds its own frozen store (`:34`), so nothing but the `whereBroadwayRoot()` styling ties it here. Pairs with the hosting-test conversion in [`Shared/Periscope/TODOs.md`](Shared/Periscope/TODOs.md), which needs the same bundle — do them together. (pr#101 review) - - test(SwiftDataInspector) [needs-design]: Move the `SwiftDataInspector` snapshot into SwiftDataInspector. `Where/WhereUI/SnapshotTests/SwiftDataInspectorSnapshotTests.swift` reaches through `PreviewSupport.previewServices()`, a seeded manual day, and a real `WhereSession` just to obtain a configuration (`:12`–`:21`) — so an app-agnostic module is snapshotted through Where's store. The module's own in-memory `@Model` fixtures would render it without the app, matching how its unit tests already work. (pr#101 review) - perf(StuffTestHost) [needs-design]: Two loose ends in the shared test host, both reaching the root Tuist manifest, which is why they sit here rather than in a StuffTestHost file. The WhereCore-always-embedded build trade-off is documented and verified load-bearing at `Project.swift:256` — decide whether to keep documenting it or split the host so unrelated bundles don't pay for it. Separately, the scene configuration name is spelled twice, in `Shared/StuffTestHost/Sources/AppDelegate.swift:11` and `Project.swift:244`, so the two can drift silently. (audit 2026-07-26) # Completed issues + +## P2s (Nice to have) +- test(WhereUI) [needs-design]: broken-snapshots — two snapshot suites pinned views WhereUI doesn't own (`PeriscopeViewer` and `SwiftDataInspector`), flagged `[Fix later]` on PR #101. (Resolved: both suites, and their reference images, now live under the module they cover. The prerequisite this item assumed — a snapshot *bundle* per module, each with its own scheme and CI job — turned out to be the wrong shape and was **not** built. Per-module bundles are actively unsafe: each `.xctest` statically embeds what it links, so a second one would carry a second copy of `SnapshotKitTesting`, whose "process-global" capture state is module-global and therefore per copy — two copies co-loaded into one `StuffTestHost` would flip the safe-area swizzle's parity against each other and neither capture lock would see the other's captures (verified with `nm`: each built bundle defines its own private `_swizzleDepth`). And per-module bundles were never needed for ownership: swift-snapshot-testing derives the `__Snapshots__` directory from the calling file's `#filePath`, so a suite records beside itself wherever it lives. So the single bundle was renamed `WhereUISnapshotTests` → `StuffSnapshotTests` and gained a `sources` directory per module, keeping one scheme and one CI job. The one-bundle rule is now recorded in the root `AGENTS.md` "Targets" section and in `SnapshotKitTesting/AGENTS.md`. Moving `PeriscopeViewer` changed no pixels — it seeds its own `periscopeBroadwayRoot()`, so the `whereBroadwayRoot()` wrapper the old suite credited for "app styling" was contributing nothing; `SwiftDataInspector` did re-record, since it moved off Where's store onto a local fixture schema.) diff --git a/Where/AGENTS.md b/Where/AGENTS.md index caaa7059..3aacdd6c 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -265,6 +265,7 @@ Root [testing conventions](../AGENTS.md#testing) apply. What's specific here: remote-import path is exercised with the `@_spi(Testing)` `inMemory(remoteChangeSource:)` + `ScriptedStoreRemoteChangeSource`. - How screens render is pinned by the matrixed image snapshots in - `WhereUISnapshotTests` (own scheme + CI job, not `Stuff-iOS-Tests`) — see + `WhereUI/SnapshotTests/` (compiled into the repo-wide `StuffSnapshotTests` + bundle — own scheme + CI job, not `Stuff-iOS-Tests`) — see [`WhereUI/AGENTS.md`](WhereUI/AGENTS.md#testing). Don't add "hosts without - crashing" smoke tests for surfaces that bundle covers. + crashing" smoke tests for surfaces those suites cover. diff --git a/Where/TODOs.md b/Where/TODOs.md index 2d0bf3a1..1750d458 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -95,7 +95,7 @@ The item format and the placement rule live in the root - test(WhereUI) [needs-design]: Snapshot matrix gaps — `LocationsView`/`YearView`'s empty states, `RecentActivitySummaryView.loading` (the sole user of `AppIconActivityIndicator`, so its `@MotionIsStatic` pinning is the one motion adoption without direct capture coverage), and `ManualDayView`'s range-mode add have no snapshot case. (From the July 2026 snapshot-testing PR review.) ## Deferred snapshot-test flakiness -Known nondeterminism in `WhereUISnapshotTests`, accepted for now — scattered +Known nondeterminism in the WhereUI image suites, accepted for now — scattered failures in these areas are expected and shouldn't be papered over by blind re-recording: diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index d7f3ff00..c20735ea 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -153,17 +153,21 @@ change. Broader WhereUI testing conventions (hosted bundles, `PreviewSupport`, required previews) live in the feature [`Where/AGENTS.md`](../AGENTS.md). Screens, widgets, and app-flow surfaces are pinned as matrixed image snapshots -in the `WhereUISnapshotTests` bundle ([`SnapshotTests/`](SnapshotTests)) — that -bundle, not hosting smoke tests, owns "does this screen render". **Each view +under [`SnapshotTests/`](SnapshotTests) — those, not hosting smoke tests, own +"does this screen render". The files compile into the repo-wide +`StuffSnapshotTests` bundle (shared with the other modules' image suites; see +the root [`AGENTS.md`](../../AGENTS.md#targets) for why there is exactly one +snapshot bundle), but they live and record their references here, next to the +views they cover. **Each view declares its matrix once, in its own source file**, via a `SnapshotProviding` conformance under `#if DEBUG` whose `#Preview` renders `Self.snapshotPreviews` — so one declaration drives both the Xcode cutsheet and the image tests (the `whereSnapshot(...)` helper + config presets live in [`Sources/Preview/WhereSnapshot.swift`](Sources/Preview/WhereSnapshot.swift)). -The test bundle is **one `FooSnapshotTests` suite per view**, each in its own -file calling `assertSnapshots(of: FooView.self)`, so each view's references sit -in their own `__Snapshots__//` directory. The bundle runs in -its own `WhereUISnapshotTests` scheme and CI job, deliberately outside +The suites are **one `FooSnapshotTests` per view**, each in its own file calling +`assertSnapshots(of: FooView.self)`, so each view's references sit in their own +`__Snapshots__//` directory. They run in the +`StuffSnapshotTests` scheme and its CI job, deliberately outside `Stuff-iOS-Tests` (see `Project.swift`). To re-record a reference, delete the PNG under `SnapshotTests/__Snapshots__/` (LFS-tracked) and run the scheme — the suites record `.missing`, and a recording run fails by design so it can't pass as diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 3f0f32a8..c7de7d21 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -141,19 +141,20 @@ the on-disk/CloudKit store). Internal types are reached via How screens *look* is pinned separately: every top-level screen, widget, and app-flow surface has matrixed image snapshots (light/dark, Dynamic Type, iPhone/iPad, contrast, right-to-left, VoiceOver annotations) in -[`SnapshotTests/`](SnapshotTests) (`WhereUISnapshotTests`), with reference -images under `SnapshotTests/__Snapshots__/` in Git LFS. Each view declares its -matrix via a `SnapshotProviding` conformance **in its own source file**, shared -with its `#Preview` cutsheet (`Self.snapshotPreviews`); the test bundle is one -`FooSnapshotTests` suite per view, so each view's references live in their own -`__Snapshots__/` directory. The -bundle has its own scheme and CI job; to re-record after an intentional UI -change, forward the record mode into the test process (see the +[`SnapshotTests/`](SnapshotTests), with reference images under +`SnapshotTests/__Snapshots__/` in Git LFS. Each view declares its matrix via a +`SnapshotProviding` conformance **in its own source file**, shared with its +`#Preview` cutsheet (`Self.snapshotPreviews`); there is one `FooSnapshotTests` +suite per view, so each view's references live in their own `__Snapshots__/` +directory. These files compile into the repo-wide `StuffSnapshotTests` bundle +alongside the other modules' image suites, which has its own scheme and CI job; +to re-record after an intentional UI change, forward the record mode into the +test process (see the [SnapshotKitTesting README](../../Shared/SnapshotKitTesting/README.md#recording) for the mode values): ```bash -TEST_RUNNER_SNAPSHOT_RECORD=failed mise exec -- tuist test WhereUISnapshotTests \ +TEST_RUNNER_SNAPSHOT_RECORD=failed mise exec -- tuist test StuffSnapshotTests \ --no-selective-testing -- \ -destination "platform=iOS Simulator,id=$(./simulator --os 27.0)" ``` diff --git a/Where/WhereUI/SnapshotTests/SwiftDataInspectorSnapshotTests.swift b/Where/WhereUI/SnapshotTests/SwiftDataInspectorSnapshotTests.swift deleted file mode 100644 index 183861ae..00000000 --- a/Where/WhereUI/SnapshotTests/SwiftDataInspectorSnapshotTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -import RegionKit -import SnapshotKitTesting -import SwiftDataInspector -import SwiftUI -import Testing -import WhereCore -@testable import WhereUI - -@MainActor -struct SwiftDataInspectorSnapshotTests { - @Test func swiftDataInspector() async throws { - let services = PreviewSupport.previewServices() - // Seed one real row so the inspector renders live content, not an empty - // store (mirrors SwiftDataInspectorWiringTests). - try await services.journal.addManualDay( - date: Date(timeIntervalSince1970: 1_770_000_000), - regions: [.california], - audit: nil, - ) - let session = WhereSession(services: services) - let configuration = try #require(session.swiftDataInspectorConfiguration) - let view = NavigationStack { - SwiftDataInspectorView(configuration: configuration) - } - .whereBroadwayRoot() - await assertSnapshots( - of: view, - named: "SwiftDataInspector", - configurations: SnapshotConfiguration.combinations( - devices: [.iPhone], - colorSchemes: [.light, .dark], - ), - ) - } -} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png deleted file mode 100644 index 3ab119b6..00000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0eb42a6abee77af8c3744866ae07c0efefc2fc5c741abe5ec6e03322974b6a86 -size 182296 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone_dark.png deleted file mode 100644 index 42f7edc4..00000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/SwiftDataInspectorSnapshotTests/swiftDataInspector.SwiftDataInspector_iPhone_dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:24d10ff029bf4691dffa034b66d5fd4f44e0f988a5674a6f5ab1ed4466cf6ebc -size 153628 diff --git a/Where/WhereUI/Sources/Shared/MotionIsStatic.swift b/Where/WhereUI/Sources/Shared/MotionIsStatic.swift index c79f7c87..fceff17e 100644 --- a/Where/WhereUI/Sources/Shared/MotionIsStatic.swift +++ b/Where/WhereUI/Sources/Shared/MotionIsStatic.swift @@ -24,7 +24,7 @@ import SwiftUI /// populates their environment before `body`, so the two reads resolve exactly /// as they would on the view itself. Proven end-to-end by the `launchSplash.*` /// captures (the pulse/radar freeze only if this reads the capture flag) and by -/// `WhereUISnapshotTests.SnapshotCaptureFlagProbeTests`. +/// `StuffSnapshotTests.SnapshotCaptureFlagProbeTests`. @propertyWrapper struct MotionIsStatic: DynamicProperty { @Environment(\.accessibilityReduceMotion) private var reduceMotion diff --git a/Where/WhereUI/Sources/Shared/SnapshotCaptureFlagProbe.swift b/Where/WhereUI/Sources/Shared/SnapshotCaptureFlagProbe.swift index fbea65e5..5ae5229d 100644 --- a/Where/WhereUI/Sources/Shared/SnapshotCaptureFlagProbe.swift +++ b/Where/WhereUI/Sources/Shared/SnapshotCaptureFlagProbe.swift @@ -7,9 +7,9 @@ /// /// This view exists to be *compiled into WhereUI* (the dynamic framework) and /// pixel-probed from the snapshot bundle - /// (`WhereUISnapshotTests.SnapshotCaptureFlagProbeTests`): the bundle + /// (`StuffSnapshotTests.SnapshotCaptureFlagProbeTests`): the bundle /// statically embeds a second copy of `SnapshotKit` via `SnapshotKitTesting` - /// (see the WhereUISnapshotTests comment in `Project.swift`), so the pipeline + /// (see the StuffSnapshotTests comment in `Project.swift`), so the pipeline /// *writes* `SnapshotCaptureTrait` in the bundle's copy while WhereUI's /// stand-ins *read* `\.isCapturingSnapshot` through WhereUI's copy. If those /// copies ever split (the duplicate-type-metadata hazard in the root diff --git a/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift b/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift index 60cfedc2..e62d45c1 100644 --- a/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift +++ b/Where/WhereUI/Tests/SwiftDataInspectorWiringTests.swift @@ -4,8 +4,9 @@ import Testing /// Verifies the DEBUG-only SwiftData inspector entry point is wired to the live /// Where store correctly: the configuration's model types track the real schema. -/// (Rendering against a seeded store is covered by the inspector snapshot in -/// `WhereUISnapshotTests`.) +/// (How the inspector *renders* is covered by SwiftDataInspector's own image +/// snapshots, over that module's fixture schema rather than Where's store — so +/// this is the only test tying the inspector to the real one.) @MainActor struct SwiftDataInspectorWiringTests { @Test func configurationModelTypesMatchTheLiveSchema() throws { diff --git a/profile b/profile index 266230e2..66e4669a 100755 --- a/profile +++ b/profile @@ -7,7 +7,7 @@ set -euo pipefail # It runs a clean `build-for-testing` (capturing Xcode's build-timing summary # and any slow type-check warnings) and two test legs — the aggregate # `Stuff-Workspace` scheme for the unit-test bundles, then the standalone -# `WhereUISnapshotTests` scheme for the image-snapshot suite — reading +# `StuffSnapshotTests` scheme for the image-snapshot suite — reading # authoritative per-test durations straight out of each .xcresult via # xcresulttool. It then prints the slowest build phases, the slowest tests, # and per-bundle totals, with both test legs folded into one report. @@ -50,12 +50,12 @@ Usage: ./profile [options] Profiles Where's clean build and its test run — the unit-test bundles via the Stuff-Workspace scheme plus the image-snapshot suite via its standalone -WhereUISnapshotTests scheme — then prints the hot spots. +StuffSnapshotTests scheme — then prints the hot spots. Options: --build-only Only profile the build --tests-only Only profile the tests - --no-snapshots Skip the WhereUISnapshotTests leg (saves ~10-15 min) + --no-snapshots Skip the StuffSnapshotTests leg (saves ~10-15 min) --device NAME Simulator device name (default: "iPhone 17") --os VERSION Simulator iOS version (default: "27.0") --top N How many slowest tests to list (default: 15) @@ -90,7 +90,7 @@ cd "$(dirname "$0")" WORKSPACE="Stuff.xcworkspace" SCHEME="Stuff-Workspace" -SNAPSHOT_SCHEME="WhereUISnapshotTests" +SNAPSHOT_SCHEME="StuffSnapshotTests" # Boot the target up front and address it by UDID: a cold simulator otherwise # lands in the timings as build/test cost, and a name-based destination can # resolve to a same-named device on another runtime. `./simulator` hands back @@ -208,7 +208,7 @@ if [ "$DO_TESTS" = true ]; then rm -rf "$RESULT_BUNDLE" SECONDS=0 set +e - # The autogenerated workspace scheme lists WhereUISnapshotTests in its + # The autogenerated workspace scheme lists StuffSnapshotTests in its # test action, but the suite is only valid under its own scheme (env # pins, serial capture) — skip it here; the snapshot leg below runs it # properly.