Skip to content
36 changes: 24 additions & 12 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,19 +278,31 @@ let project = Project(
]),
]),
sources: ["Shared/StuffTestHost/Sources/**"],
// Hosted Swift Testing bundles run inside StuffTestHost, so a package's
// `Bundle.module` resolves against the host app's main bundle at runtime.
// WhereCore is load-bearing here: depending on it embeds
// `Stuff_WhereCore.bundle` (its SwiftData schema/resources) and — because
// WhereCore depends on RegionKit — the GeoJSON `Stuff_RegionKit.bundle`
// into the host, so code the tests touch (e.g. `RegionAttributor.shared`,
// the live SwiftData store) finds its resources instead of trapping in the
// `Bundle.module` accessor. Verified load-bearing: dropping it fails
// WhereUITests' StringsTests + SwiftDataInspectorWiringTests. RegionKit
// itself needs no separate entry — WhereCore carries it in (also verified
// by running the full scheme without a direct RegionKit dep).
// Keep this host thin — it deliberately depends on nothing but
// TestHostSupport.
//
// It used to also depend on WhereCore, to embed `Stuff_WhereCore.bundle`
// and (transitively) the GeoJSON `Stuff_RegionKit.bundle` into the host,
// on the theory that a hosted bundle's `Bundle.module` resolves against
// the host's main bundle. That is no longer true, and on Xcode 27 it is
// no longer needed: each `.xctest` now carries its own copies of the
// resource bundles for the code it links (`WhereUITests.xctest` ships
// `Stuff_WhereCore.bundle`, `Stuff_RegionKit.bundle`,
// `Stuff_WhereUI.bundle`, `Stuff_LifecycleKitUI.bundle`), so SwiftPM's
// `Bundle.module` finds them via `Bundle(for: BundleFinder.self)` — the
// test bundle — and never falls back to `Bundle.main`. Verified by
// removing the dependency and running the full `Stuff-iOS-Tests` and
// image-snapshot schemes green, with the built host confirmed to contain
// no WhereCore symbols and no resource bundles at all. (One of the two
// canaries the old note cited as proof, `WhereUITests.StringsTests`, had
// also ceased to exist with the String Catalog symbol migration.)
//
// Don't re-add a product here to fix a missing-resource failure: that
// makes every unrelated bundle in the scheme pay for it, and duplicates
// the payload (the whole GeoJSON set was being embedded twice). Give the
// bundle that needs the resource a dependency on the product that owns
// it instead.
dependencies: [
.package(product: "WhereCore"),
// Lets `SceneDelegate` stamp its window with `isMainTestHostWindow`
// so hosted tests can find it via `TestHostSupport.hostKeyWindow()`.
.package(product: "TestHostSupport"),
Expand Down
33 changes: 15 additions & 18 deletions Shared/StuffTestHost/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,21 @@ system, formatting, and global conventions. Read that first.
`UIApplicationSceneManifest` in `Project.swift` — and they must stay aligned
or the scene never connects and every hosted test loses its window.

## Bundle.module embedding checklist

When a hosted test bundle exercises SPM code that loads **processed
resources** via `Bundle.module`, the resource bundle must be embedded in
**this host app**:

1. Confirm the library uses `.process(...)` resources in
[`Package.swift`](../../Package.swift).
2. Add `.package(product: "<Library>")` to the `StuffTestHost` target in
[`Project.swift`](../../Project.swift) (not only to the test bundle).
3. Regenerate (`./ide --no-open`) and verify `Stuff_<Module>.bundle` lands in
the host's embedded resources.
4. Add or extend a smoke test that touches the resource path so a missing
embed fails in CI.

Today the host embeds **WhereCore** for GeoJSON region polygons even when a
test bundle doesn't import WhereCore — a deliberate trade-off documented here
until a slimmer host split is designed.
## Don't add products here for `Bundle.module`

The host depends on `TestHostSupport` and nothing else, and embeds no resource
bundles. A hosted test's `Bundle.module` resolves through `Bundle(for:)` — the
`.xctest`, which on Xcode 27 carries its own copies of the resource bundles for
the code it links — so it never falls back to the host's `Bundle.main`. That
follows from every product linking statically into each consumer, which the root
[`AGENTS.md`](../../AGENTS.md#never-double-link-a-product-whereui-already-carries)
records.

A missing-resource failure is therefore fixed on the *test bundle* that needs
it, by depending on the product that owns the resource; adding the product to
the host makes every unrelated bundle in the scheme carry it. The
`StuffTestHost` target in [`Project.swift`](../../Project.swift) records why the
host's old `WhereCore` dependency was removed.

## Testing

Expand Down
25 changes: 15 additions & 10 deletions Shared/StuffTestHost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@ plus `TestHostSupport` and run inside this app (see [`Project.swift`](../../Proj
empty `UIViewController` as root and marks it `isMainTestHostWindow` (from
`TestHostSupport`) so `hostKeyWindow()` can find it.

## Bundle.module embedding

Some SPM resources (notably WhereCore GeoJSON region data) resolve via
`Bundle.module` at runtime. In a hosted test, that bundle must be **embedded in
the host app**, not only linked into the test bundle — otherwise `Bundle.module`
traps when code first touches those resources.

`StuffTestHost` depends on `WhereCore` in `Project.swift` so Tuist embeds
`Stuff_WhereCore.bundle` into the host. See [`AGENTS.md`](AGENTS.md) for the
checklist when adding modules with processed resources.
## Bundle.module and resources

Some SPM resources (notably WhereCore's GeoJSON region data) resolve via
`Bundle.module` at runtime. That accessor looks the bundle up with
`Bundle(for:)` — the bundle the code is linked into — and on Xcode 27 each
`.xctest` carries its own copies of the resource bundles for what it links, so a
hosted test finds them in the test bundle and never falls back to the host's
`Bundle.main`.

So the host embeds no resource bundles and depends on nothing but
`TestHostSupport`. If a bundle hits a missing resource, give *that* bundle a
dependency on the product that owns it rather than adding the product to the
host, which would make every unrelated bundle in the scheme pay for it. The
`StuffTestHost` target in [`Project.swift`](../../Project.swift) carries the
long form of this.

## Testing the host

Expand Down
3 changes: 2 additions & 1 deletion TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,14 @@ inbox rather than here.
- Dead end: Tuist's `PackageSettings(productTypes:)` does nothing here. The local package is wired as an `XCLocalSwiftPackageReference` and resolved by Xcode's own SPM integration, so Tuist's product-type machinery never applies — only SwiftPM's `type:`. Relatedly, `type: .dynamic` takes effect only for a product an Xcode target consumes *as a product*: WhereUI depends on the SnapshotKit *target*, so SnapshotKit stayed static despite the annotation.
- Trap: **a shared DerivedData reports false negatives here.** Two separate runs reported "no frameworks produced" from an incremental build that had not re-resolved the package graph. Spike this into a fresh `-derivedDataPath` or it will lie to you.
- feat(PeriscopeCore) [needs-design]: A `LogSession` can't name the build it came from. `LogSession.current()` reads only `CFBundleShortVersionString` / `CFBundleVersion` off the main bundle (`Shared/Periscope/PeriscopeCore/Sources/Store/LogSession.swift:32`), and the viewer renders that pair as the session's identity (`PeriscopeTools/Sources/Viewer/PeriscopeViewer.swift:269`) — but the Where app pins both in the manifest (`Project.swift:101`), so every developer build reads `v1.0 (1)` and weeks-old logs can't be tied to the code that produced them. The commit is now available: the app stamps `WhereGitSHA` / `WhereGitStatus` into its Info.plist and `WhereCore.BuildInfo` reads them back. PeriscopeCore can't depend on WhereCore (Periscope sits below the Where modules), so this needs a seam rather than a direct adoption — an optional commit field, or general resource attributes on `LogSession` that the host app fills at bootstrap. Spans Periscope and Where, hence here. (agent 2026-07-26)
- 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)
- refactor(StuffTestHost) [quick-win]: The scene configuration name is spelled twice, in `Shared/StuffTestHost/Sources/AppDelegate.swift:12` and `Project.swift:271`, so the two can drift silently. Reaches the root Tuist manifest, which is why it sits here rather than in a StuffTestHost file. (audit 2026-07-26)

# Completed issues

## P1s (Should do)
- test(CreditKit) [needs-design]: The attribution drift guard didn't detect a stale report, so the app could ship notices that don't govern the code in it. **Closed by `./attribution --check`**, a network-free mode that re-derives the expected report from `Package.swift`, `Package.resolved`, and the skills manifest and diffs it against the committed one, gated in CI beside the other lints (`.github/workflows/ci.yml`). The literal name lists in `AppAttributionTests` are gone — a test bundle can't read the manifests, so all it could compare against was a literal — and that suite now asserts only what the shipping bundle can answer. **As originally filed this item was wrong on a detail worth recording:** it claimed the old guard caught an added dependency but not a bumped one. It caught neither. Comparing the report to a hardcoded list means a dependency added without regenerating leaves report and list still agreeing, which is exactly what happened — the guard passed on a report missing the two snapshot packages that merging `main` had added. (pr#140 review, closed 2026-07-26)

## P2s (Nice to have)
- perf(StuffTestHost) [needs-design]: The WhereCore-always-embedded build trade-off in the shared test host — every bundle in the scheme paid for it, so the item was whether to keep documenting it or split the host. (Resolved by deleting it instead: on Xcode 27 the dependency is simply unnecessary. Each `.xctest` now carries its own copies of the resource bundles for the code it links, so SwiftPM's `Bundle.module` resolves via `Bundle(for:)` — the test bundle — and never falls back to `Bundle.main`, which was the only thing the host embed provided. Verified by removing it and running both `Stuff-iOS-Tests` and the image-snapshot scheme green, with the built host confirmed to hold no WhereCore symbols and no resource bundles. It also removes duplicate payload — the full GeoJSON set was embedded in both the host and every bundle that needed it. Independently, one of the two canaries the old note cited as proof, `WhereUITests.StringsTests`, no longer existed: it went with the String Catalog symbol migration.)
- 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.)
- **Correction (2026-07-26, same day):** the "per-module bundles are actively unsafe" reasoning above was wrong, and the single-bundle shape it produced has been undone. It assumed several `.xctest` bundles in one scheme load into one `StuffTestHost` process — inherited from the root `AGENTS.md` double-linking note rather than measured. They don't: probing `ProcessInfo.processIdentifier` from two bundles in one scheme reports different PIDs, on both a filtered and a full unfiltered run (Xcode 27). Each bundle gets its own host process, so its own copy of `SnapshotKitTesting`'s capture state is genuinely process-wide and cannot collide with another bundle's. The repo now has one image bundle per module (`WhereUISnapshotTests`, `PeriscopeToolsSnapshotTests`, `SwiftDataInspectorSnapshotTests`) gathered into the one `StuffSnapshotTests` scheme — which also lets the Periscope and SwiftDataInspector suites stop linking WhereUI. Left here rather than edited away because the wrong reasoning is the useful part: two separate load-bearing claims in these docs turned out to be inherited rather than verified.
Loading