Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Module>/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
Expand Down
67 changes: 47 additions & 20 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions Shared/Periscope/PeriscopeTools/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
8 changes: 8 additions & 0 deletions Shared/Periscope/PeriscopeTools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
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 {
let store = try await Self.frozenStore()
let viewer = NavigationStack {
PeriscopeViewer(store: store, title: "Logs")
}
.whereBroadwayRoot()
await assertSnapshots(
of: viewer,
named: "PeriscopeViewer",
Expand All @@ -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",
Expand All @@ -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: [],
Expand Down
Loading
Loading