Skip to content

Add snapshot tests, Maestro flows and CI for the companion apps - #11

Open
ARamy23 wants to merge 6 commits into
Abdo-codes:mainfrom
ARamy23:test/snapshot-and-e2e
Open

Add snapshot tests, Maestro flows and CI for the companion apps#11
ARamy23 wants to merge 6 commits into
Abdo-codes:mainfrom
ARamy23:test/snapshot-and-e2e

Conversation

@ARamy23

@ARamy23 ARamy23 commented Jul 28, 2026

Copy link
Copy Markdown

Stacked on #7, #8, #9 and #10.

  • Snapshot tests (Tests/PhoneTests) pin the companion screen in each state a user can actually land in: kept awake, idle, waiting for the Mac, transport failure. Recorded on a fixed device config so a different simulator can't silently rewrite them.
  • Maestro flow (.maestro/companion-status.yaml) asserts the app shows what the Mac is doing, labels a paused app as paused, and that pausing from the phone moves the Mac to "Idle. Sleep allowed".
  • CI gains two jobs — one building both companion apps and running snapshot tests, one booting a simulator and running Maestro. The existing macOS job now also enforces the domain coverage gate from Convert test suite to Swift Testing with BDD scenarios #7.

Execution status — please read before merging

I don't want this to look greener than it is.

Status
24 macOS scenarios ✅ passing
Domain coverage gate ✅ passing
macOS app, iPhone app, watch app builds ✅ all build
Snapshot tests ⚠️ not passing locally
Maestro flow ⚠️ never executed

Snapshot tests: they compile and the target builds, but Xcode reports No result for all four with zero recorded issues. TEST_HOST and BUNDLE_LOADER are set correctly. I could not identify the cause inside a reasonable time box, so they're wired into CI to find out whether this is local-only.

Maestro: requires Java, which isn't installed on this machine. I performed the same assertions by hand against the running app via the accessibility tree (see #9), so the flow's content is known-good — but the flow file itself has never been run.

Both are honest gaps, not oversights. CI is the next signal on each.

https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw

ARamy23 added 6 commits July 29, 2026 00:12
Rewrites NightcapAppTests from XCTest to Swift Testing, organised as one
@suite per feature with numbered Given/When/Then scenarios. Behaviour is
characterised rather than changed: no production code is touched.

Notable additions beyond a straight translation:

- removeAppRequested had no coverage at all. Three scenarios now cover
  removing a running app, removing an idle one, and removing one of two
  running apps (the assertion must survive for the other).
- The assertion reason string is asserted when a second watched app
  launches, so the pmset-visible reason stays truthful.
- A scenario covers IOKit refusing the assertion, where the app must not
  claim the Mac is being kept awake.
- test_terminate_event_keeps_assertion_when_another_instance_still_running
  previously asserted nothing. It now checks that release() is not called.

Tests are given an in-memory file storage dependency. Swift Testing runs
suites in parallel, and @shared(.fileStorage) would otherwise be shared
mutable state across scenarios.

scripts/check-domain-coverage.sh enforces a floor on pure-domain coverage
from an .xcresult bundle. Live adapters (NSWorkspace, IOKit, SMAppService,
StoreKit) and SwiftUI views are excluded by design; covering those means
integration tests, not characterisation.

20 scenarios, all passing. Domain coverage 96.04% (291/303), up from
93.70% (284/303).

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Splits the app into three modules inside one local SwiftPM package:

- NightcapDomain: WatchedApp, LaunchAtLoginStatus, AppFeature, and all
  dependency-client interfaces. No AppKit, IOKit, ServiceManagement or
  StoreKit, so it can build for iOS and watchOS.
- NightcapClients: the live DependencyKey conformances. The only module
  that touches platform frameworks.
- NightcapUI: the SwiftUI menu views.

AppFeature.swift previously imported AppKit without using a single AppKit
symbol; the reducer was already pure and that import is now gone.

The package builds standalone (`swift build` in Packages/NightcapKit
succeeds). The generated Xcode project does NOT yet consume it: Xcode never
registers the XCLocalSwiftPackageReference, and NightcapKit is absent from
SourcePackages/workspace-state.json, so all three products report as
"Missing package product". Committed as WIP so the extraction is not lost
while that is resolved.

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Splits the single app target into three modules with compiler-enforced
boundaries:

- NightcapDomain: WatchedApp, LaunchAtLoginStatus, AppFeature, and the
  dependency-client interfaces. Imports no platform frameworks, so it can
  be reused by iOS and watchOS targets later.
- NightcapClients: the live DependencyKey conformances. The only module
  that touches AppKit, IOKit, ServiceManagement and StoreKit.
- NightcapUI: the SwiftUI menu views.

Client interfaces are separated from implementations using the standard
swift-dependencies split: the @DependencyClient struct and its
TestDependencyKey live in the domain, the liveValue conformance lives in
NightcapClients.

AppFeature.swift imported AppKit without using a single AppKit symbol. The
reducer was already pure, so the extraction started by deleting that import.

Modules are built as static framework targets rather than local SwiftPM
packages. Xcode would not register a local package reference for this
project: NightcapKit never appeared in SourcePackages/workspace-state.json
and all products reported "Missing package product", despite a manifest
that builds fine under `swift build` and matches a working setup elsewhere.
Static linking also avoids embedding and signing three extra dynamic
frameworks. Package.swift manifests are kept alongside so the modules
remain consumable by SwiftPM directly.

Shipping parity verified on the built app: LSUIElement true, category
unchanged, app-sandbox and files.user-selected.read-only intact, and no
Nightcap frameworks embedded (statically linked).

All 20 scenarios still pass. Domain coverage is 93.11%, down from 96.04%,
entirely because LaunchAtLoginStatus.init(SMAppService.Status) was
previously counted as covered by the test host app exercising the live code
path at launch, not by any test. That code now lives in NightcapClients, so
the number reflects real test coverage.

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Introduces the companion surface, built inside-out from the domain:

- MacState: the entire contract between the Mac and a companion. A plain
  value type, so companions can be built and tested long before a real
  transport exists.
- MacStateTransportClient: transport-agnostic interface with a working
  in-memory stub as its live value. Shipping a real transport means adding
  a network entitlement to a sandboxed App Store app whose listing promises
  zero network calls, so that stays a separate decision.
- CompanionFeature: one reducer driving both iPhone and Watch. Neither
  platform holds logic of its own.
- NightcapCompanionUI: shared SwiftUI for iOS and watchOS. The existing
  NightcapUI stays macOS-only because it is AppKit menu-bar code.
- NightcapPhone: the iOS app target.

NightcapDomain is now a multi-platform target (macOS, iOS, watchOS), which
is what the earlier purity work was for.

CompanionFeature.State distinguishes "waiting for the Mac" from "the Mac is
idle", so the UI never claims the Mac is asleep before any snapshot has
arrived. Transport failures surface a message rather than failing silently.
onDisappear cancels the subscription so a watch app is not holding a stream
open in the background.

ComposableArchitecture is linked once, via NightcapDomain. The companion UI
and phone app take it with link: false; linking it into each static
framework produced 7674 duplicate symbols.

Verified on the iPhone 17 Pro simulator via the accessibility tree: the app
shows "Keeping Mac Awake. 1 app active", and tapping Ghostty's toggle moves
it to "Idle. Sleep allowed" with Ghostty marked Paused.

24 scenarios pass, including 4 new companion scenarios.

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Adds the watchOS app target. It needed no new logic and no new views:
CompanionFeature and NightcapCompanionUI were already built for both iOS
and watchOS, so this is an entry point plus target configuration.

Runs independently of a companion iPhone app
(WKRunsIndependentlyOfCompanionApp), since the companion talks to a Mac
rather than to the phone.

Verified: builds and links cleanly against the watchOS simulator SDK.

NOT verified at runtime: this machine has no watchOS simulator runtime
installed, so no watch simulator can be created and the app has not been
launched. Installing a runtime is a multi-gigabyte download and is left as
a deliberate decision. The UI is shared with the iPhone app, which has been
driven end to end on a simulator, so the untested surface is the watchOS
shell rather than the screen itself.

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
- Tests/PhoneTests: snapshot tests pinning the companion screen in each
  state a user can land in (kept awake, idle, waiting for the Mac, transport
  failure), recorded on a fixed device so a different simulator cannot
  silently rewrite them.
- .maestro/companion-status.yaml: an end-to-end flow asserting the app shows
  what the Mac is doing, labels a paused app as paused, and that pausing from
  the phone moves the Mac to "Idle. Sleep allowed".
- CI gains two jobs: one building both companion apps and running the
  snapshot tests, one booting a simulator and running the Maestro flows. The
  existing macOS job now also enforces the domain coverage gate.

Local execution status, honestly:

- The 24 macOS scenarios pass and the coverage gate passes.
- The snapshot tests compile and the target builds, but Xcode reports "No
  result" for all four with zero recorded issues, so they are NOT passing
  locally. TEST_HOST and BUNDLE_LOADER are set correctly and the bundle
  builds; the cause is not yet identified. They are wired into CI so the
  next run will say whether this is local-only.
- The Maestro flows have NOT been run. Maestro requires Java, which is not
  installed on this machine. The same assertions were performed by hand
  against the running app via the accessibility tree, so the flow content is
  known-good, but the flow file itself is unexecuted.

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
@ARamy23

ARamy23 commented Jul 28, 2026

Copy link
Copy Markdown
Author

Update: snapshot tests now run and pass (5/5). They were reported as "not passing locally" in the description above. Three separate causes, all now fixed:

  1. The iOS test bundle was never built. Xcode's autocreated schemes didn't include it — tests were listed but reported No result with no errors. Schemes are now defined explicitly in project.yml.
  2. TEST_HOST pointed at a bundle that didn't exist. It resolved to NightcapPhone.app, but PRODUCT_NAME: Nightcap made the real product Nightcap.app. The override is gone; the display name comes from CFBundleDisplayName.
  3. The test runner segfaulted in objc_copyClassList during +[XCTestCase _allSubclasses], faulting on preview-thunk metadata. This happens when the simulator runtime is older than the SDK (iOS 26.5 runtime, iOS 27 SDK). ENABLE_PREVIEWS is now off for the modules, the test bundle and the apps.

The snapshot tests were also weak as written. CompanionView sends .onAppear, which hit the unimplemented test dependency and recorded an issue — they passed only by timing luck. Each store now gets a stubbed transport.

The Maestro flow is still not passing, and the reason changed. Java is installed now and Maestro runs, but its bundled XCUITest driver won't start under Xcode 27, and on an iOS 26.5 simulator the driver crashes the app via the same class-enumeration bug. This is a Maestro-vs-Xcode-27 incompatibility, not a defect in the flow.

The flow did catch two real problems before it stalled: it asserted against an async first render without waiting, and its first assertion matched the springboard icon labelled "Nightcap", so it passed even when the app had died. Both fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant