Skip to content

ci: add GitHub Actions CI, screenshot testing, and fix the test-process hang - #17

Merged
fardavide merged 3 commits into
mainfrom
claude/setup-ci
Aug 4, 2026
Merged

ci: add GitHub Actions CI, screenshot testing, and fix the test-process hang#17
fardavide merged 3 commits into
mainfrom
claude/setup-ci

Conversation

@fardavide

Copy link
Copy Markdown
Owner

Brings Swiftly's CI and repo rules in line with Aura's.

CI

Four jobs on macos-26, intended as required status checks:

Job Runs
Unit tests (SwiftlyCore) swift test
Build app (iOS) unsigned compile-check
Build app (macOS) unsigned compile-check
Snapshot tests (iOS) SwiftlySnapshotTests scheme on a pinned simulator

.github/actions/select-xcode pins Xcode 26.6 with a newest-26.x fallback so snapshot pixels stay reproducible. The snapshot job caches resolved SwiftPM checkouts, preflights that the pinned simulator exists, and on failure builds and uploads a self-contained HTML report pairing each fresh render against its baseline. It runs the SwiftlySnapshotTests scheme — only testable SwiftlyTests, no test plan — so it builds the app plus that one bundle rather than all 22 targets of CoreTestPlan.

Screenshot testing

Wired into the app-hosted SwiftlyTests target with swift-snapshot-testing — app-hosted because only that gives the tests a real host window. That target had an empty Sources build phase and had never compiled anything; it does now. One smoke test over ErrorView proves the harness end to end across iPhone/iPad × portrait/landscape × light/dark, baselines committed. The precision constants are an untuned first guess recorded on one machine; the first GitHub-runner run is the real calibration.

The failure path was verified end to end: corrupting a baseline produces exit 65, one artifact at the mirrored __SnapshotFailures__ path, and a readable three-pane HTML diff.

Two bugs this uncovered

swift test hung on ~3 runs in 4, after all 42 assertions passed — which would have made the unit-test check unusable as a gate. RealTurbine.awaitFirst subscribed with subject.print().first(); the subject replays its current value on subscribe and awaitFirst is only reached when that value is .notReady, so first() delivered .notReady, took the break without resuming the continuation, and completed. The awaiting task stayed suspended forever and the process could never exit. It passed at all only by race. 0 hangs in 12 consecutive runs after the fix, from 6 in 8 before.

A leaked ticker: ConverterViewModel.init added a repeats: true Timer to RunLoop.current and never invalidated it — one timer per view model, alive for the lifetime of the process. Now a syncUpdatedAt() the view drives from .task, which SwiftUI cancels on disappear.

Both predate this branch; they were only invisible because the suites that construct a ConverterViewModel had not compiled since March (fixed in 6e4d92f, also on this branch — see the commit for the Swift 6 isolation details).

Known gaps

  • SwiftLint is a build phase that warns-and-passes when the binary is absent. If macos-26 ever ships without SwiftLint, the style gate silently no-ops — a false green for lint, though not for build or test.
  • -clonedSourcePackagesDirPath points outside the checkout deliberately: .swiftlint.yml excludes only SwiftlyCore/.build, so vendored sources inside the repo would fail the app build's lint phase.
  • OS=latest resolves to whatever iOS 26.x the runner ships. If baselines prove sensitive across point releases, pin the exact runtime.
  • SwiftlyTests/SwiftlyModuleTests.swift is still not compiled: it calls Provider.require(), which does not exist. Dead as written — fix or delete separately.

🤖 Generated with Claude Code

fardavide and others added 3 commits August 4, 2026 15:21
`ConverterPresentationTests` and `AboutPresentationTests` have not
compiled since 9dd23d9 put `@MainActor` on the `ViewModel` protocol — 9
tests dark since 2026-03-26. `CoreTestPlan` could not build either, so
`swift test` failed outright and no package test was runnable in CI.

Reading a view model's `$state` from a nonisolated test sends a
non-Sendable `Published.Publisher` across an isolation boundary, and
constructing the view model calls a main-actor-isolated init. Fixed by
isolating the tests to the actor the code under test already lives on,
rather than by weakening the production isolation:

- `test(_:block:)` and its block are `@MainActor`, and `Turbine` is
  `Sendable`, so awaiting a turbine from the test body is not a crossing
- both suites and their `Scenario` fixtures are `@MainActor`
- `send` is awaited at the call sites, which also makes those tests
  deterministic rather than fire-and-forget
- `Lce` gains a conditional `Sendable` conformance; `GenericError`,
  `AboutUiModel`, `CurrencyValue` and `CurrencyWithRate` — all immutable
  value types — declare it

`swift test` now passes: 42 tests in 12 suites. App builds clean on iOS
and macOS, SwiftLint strict clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`swift test` passed all 42 assertions and then hung, indefinitely, on
roughly 3 runs in 4 — which makes it unusable as a required CI check.

`RealTurbine.awaitFirst` subscribed with `subject.print().first()`. The
subject replays its current value on subscribe, and `awaitFirst` is only
reached when that value is `.notReady`, so `first()` delivered
`.notReady`, took the `break` without resuming the continuation, and
completed. The awaiting task stayed suspended forever and the process
could never exit. It passed at all only by race: sometimes a `.ready`
value landed between the `subject.value` check and the subscription.
Unwrapping to `.ready` before `first()` makes exactly one resume happen.

Also removes the leaked ticker this exposed: `ConverterViewModel.init`
added a `repeats: true` Timer to `RunLoop.current` and never invalidated
it, leaking a timer per view model for the lifetime of the process. It is
now a `syncUpdatedAt()` the view drives from `.task`, which SwiftUI
cancels when the view goes away.

Measured 0 hangs in 12 consecutive `swift test` runs, from 6 in 8 before.
Also drops the stray `.print()` debug operator from the subscription.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports Aura's CI. Four jobs on macos-26, each a required status check:
"Unit tests (SwiftlyCore)" (`swift test`), "Build app (iOS)" and
"Build app (macOS)" (unsigned compile-checks), and
"Snapshot tests (iOS)".

- `.github/actions/select-xcode` pins Xcode 26.6 with a newest-26.x
  fallback, so snapshot pixels stay reproducible
- the snapshot job caches resolved SwiftPM checkouts, preflights that the
  pinned simulator exists, and on failure builds and uploads a
  self-contained HTML report pairing each fresh render with its baseline
- it runs the `SwiftlySnapshotTests` scheme, whose only testable is
  `SwiftlyTests` and which declares no test plan, so it builds the app
  plus that one bundle rather than all 22 targets of CoreTestPlan

Screenshot testing is wired into the app-hosted `SwiftlyTests` target
with swift-snapshot-testing — app-hosted because only that gives the
tests a real host window. `SwiftlyTests` had an empty Sources phase and
had never compiled anything; it does now. One smoke test over `ErrorView`
proves the harness end to end across iPhone/iPad x portrait/landscape x
light/dark, with baselines committed.

The precision constants are an untuned first guess recorded against this
machine's simulator; the first run on a GitHub runner is the real
calibration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@semanticdiff-com

semanticdiff-com Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  SwiftlyCore/Tests/About/PresentationTests/AboutViewModelTests.swift  34% smaller
  SwiftlyCore/Tests/Converter/PresentationTests/ConverterViewModelTests.swift  31% smaller
  SwiftlyCore/Sources/Common/Test/Turbine.swift  21% smaller
  .github/actions/select-xcode/action.yml  0% smaller
  .github/scripts/snapshot-report.py  0% smaller
  .github/workflows/ci.yml  0% smaller
  .gitignore Unsupported file format
  Swiftly.xcodeproj/project.pbxproj Unsupported file format
  Swiftly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved Unsupported file format
  Swiftly.xcodeproj/xcshareddata/xcschemes/SwiftlySnapshotTests.xcscheme Unsupported file format
  SwiftlyCore/Sources/About/Presentation/Models/AboutState.swift  0% smaller
  SwiftlyCore/Sources/Common/Utils/GenericError.swift  0% smaller
  SwiftlyCore/Sources/Common/Utils/Lce.swift  0% smaller
  SwiftlyCore/Sources/Converter/Presentation/ConverterViewModel.swift  0% smaller
  SwiftlyCore/Sources/Converter/Presentation/UI/ConverterView.swift  0% smaller
  SwiftlyCore/Sources/Currency/Domain/Models/CurrencyValue.swift  0% smaller
  SwiftlyCore/Sources/Currency/Domain/Models/CurrencyWithRate.swift  0% smaller
  SwiftlyTests/ErrorViewSnapshotTests.swift  0% smaller
  SwiftlyTests/SnapshotSupport.swift  0% smaller
  SwiftlyTests/__Snapshots__/ErrorViewSnapshotTests/networkErrorWithRetryMatchesReference.network-json-retry-iPad-landscape-dark.png Unsupported file format
  SwiftlyTests/__Snapshots__/ErrorViewSnapshotTests/networkErrorWithRetryMatchesReference.network-json-retry-iPad-landscape-light.png Unsupported file format
  SwiftlyTests/__Snapshots__/ErrorViewSnapshotTests/networkErrorWithRetryMatchesReference.network-json-retry-iPad-portrait-dark.png Unsupported file format
  SwiftlyTests/__Snapshots__/ErrorViewSnapshotTests/networkErrorWithRetryMatchesReference.network-json-retry-iPad-portrait-light.png Unsupported file format
  SwiftlyTests/__Snapshots__/ErrorViewSnapshotTests/networkErrorWithRetryMatchesReference.network-json-retry-iPhone-landscape-dark.png Unsupported file format
  SwiftlyTests/__Snapshots__/ErrorViewSnapshotTests/networkErrorWithRetryMatchesReference.network-json-retry-iPhone-landscape-light.png Unsupported file format
  SwiftlyTests/__Snapshots__/ErrorViewSnapshotTests/networkErrorWithRetryMatchesReference.network-json-retry-iPhone-portrait-dark.png Unsupported file format
  SwiftlyTests/__Snapshots__/ErrorViewSnapshotTests/networkErrorWithRetryMatchesReference.network-json-retry-iPhone-portrait-light.png Unsupported file format

@fardavide
fardavide merged commit 26038a7 into main Aug 4, 2026
3 of 4 checks passed
@fardavide
fardavide deleted the claude/setup-ci branch August 4, 2026 23:29
fardavide pushed a commit that referenced this pull request Aug 4, 2026
The Turbine helper's expectInitial recursed on unchanged state with no
suspension point whenever the current value equaled the expected one:
the task never yields, pinning a cooperative-pool thread and allocating
async frames without bound. Locally the view model's next emission
interrupts the spin within milliseconds, but on the small CI runners
all turbine tests spin at once, occupy the whole pool, starve the very
work that would emit the next value, and the test process dies ~90s in
with no summary — the "test-process hang" that survived PR #17.

Rebuild the helper on an AsyncStream: every emission is buffered and
consumed oldest-first, waiting is a real suspension, and expectInitial
consumes the initial value when it is still there or puts the first
value back when the source progressed before subscription. Add
TurbineTests pinning both semantics; the first test is the minimal
reproduction of the spin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Myrncy7JAwkAjnfT8jm2h6
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