ci: add GitHub Actions CI, screenshot testing, and fix the test-process hang - #17
Merged
Conversation
`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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings Swiftly's CI and repo rules in line with Aura's.
CI
Four jobs on
macos-26, intended as required status checks:Unit tests (SwiftlyCore)swift testBuild app (iOS)Build app (macOS)Snapshot tests (iOS)SwiftlySnapshotTestsscheme on a pinned simulator.github/actions/select-xcodepins 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 theSwiftlySnapshotTestsscheme — only testableSwiftlyTests, no test plan — so it builds the app plus that one bundle rather than all 22 targets ofCoreTestPlan.Screenshot testing
Wired into the app-hosted
SwiftlyTeststarget 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 overErrorViewproves 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 testhung on ~3 runs in 4, after all 42 assertions passed — which would have made the unit-test check unusable as a gate.RealTurbine.awaitFirstsubscribed withsubject.print().first(); the subject replays its current value on subscribe andawaitFirstis only reached when that value is.notReady, sofirst()delivered.notReady, took thebreakwithout 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.initadded arepeats: trueTimertoRunLoop.currentand never invalidated it — one timer per view model, alive for the lifetime of the process. Now asyncUpdatedAt()the view drives from.task, which SwiftUI cancels on disappear.Both predate this branch; they were only invisible because the suites that construct a
ConverterViewModelhad not compiled since March (fixed in6e4d92f, also on this branch — see the commit for the Swift 6 isolation details).Known gaps
macos-26ever ships without SwiftLint, the style gate silently no-ops — a false green for lint, though not for build or test.-clonedSourcePackagesDirPathpoints outside the checkout deliberately:.swiftlint.ymlexcludes onlySwiftlyCore/.build, so vendored sources inside the repo would fail the app build's lint phase.OS=latestresolves to whatever iOS 26.x the runner ships. If baselines prove sensitive across point releases, pin the exact runtime.SwiftlyTests/SwiftlyModuleTests.swiftis still not compiled: it callsProvider.require(), which does not exist. Dead as written — fix or delete separately.🤖 Generated with Claude Code