Files
Reflect/Shared/UITestMode.swift
Trey T 2ef1c1ec51 Enable parallel UI test execution via per-session data isolation
Each test class now gets a unique session ID (UUID) passed to the app
via UI_TEST_SESSION_ID environment variable. The app uses this to:

- Route GroupUserDefaults to a session-specific UserDefaults suite,
  preventing tests from clobbering each other's AppStorage state
- Create an in-memory SwiftData container instead of the shared
  on-disk App Group store, eliminating SQLite contention

Refactored 8 test classes that bypassed BaseUITestCase.setUp() with
custom launch args — they now use overridable `localeArguments` and
`extraLaunchArguments` properties, keeping session ID injection
centralized. Added `relaunchApp(resetState:bypassSubscription:)` to
BaseUITestCase for tests that need mid-test relaunch with different
subscription state.

Includes a ParallelUITests.xctestplan with class-level parallelism
enabled and random execution ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:04:55 -05:00

149 lines
5.5 KiB
Swift

//
// UITestMode.swift
// Reflect (iOS)
//
// Handles launch arguments for UI testing mode.
// When --ui-testing is passed, the app uses deterministic settings.
//
import Foundation
#if canImport(UIKit)
import UIKit
#endif
enum UITestMode {
/// Whether the app was launched in UI testing mode
static var isUITesting: Bool {
ProcessInfo.processInfo.arguments.contains("--ui-testing")
}
/// Whether to reset all state before the test run
static var shouldResetState: Bool {
ProcessInfo.processInfo.arguments.contains("--reset-state")
}
/// Whether to disable animations for faster, more deterministic tests
static var disableAnimations: Bool {
ProcessInfo.processInfo.arguments.contains("--disable-animations")
}
/// Whether to bypass the subscription paywall
static var bypassSubscription: Bool {
ProcessInfo.processInfo.arguments.contains("--bypass-subscription")
}
/// Whether to skip onboarding
static var skipOnboarding: Bool {
ProcessInfo.processInfo.arguments.contains("--skip-onboarding")
}
/// Whether to force the trial to be expired (sets firstLaunchDate to 31 days ago)
static var expireTrial: Bool {
ProcessInfo.processInfo.arguments.contains("--expire-trial")
}
/// Unique session ID for parallel test isolation.
/// Each test class gets its own session, ensuring no shared state between parallel test runners.
static var sessionID: String? {
ProcessInfo.processInfo.environment["UI_TEST_SESSION_ID"]
}
/// Seed fixture name if provided (via environment variable)
static var seedFixture: String? {
ProcessInfo.processInfo.environment["UI_TEST_FIXTURE"]
}
/// Apply all UI test mode settings. Called early in app startup.
@MainActor
static func configureIfNeeded() {
guard isUITesting else { return }
#if canImport(UIKit)
if disableAnimations {
UIView.setAnimationsEnabled(false)
}
#endif
if shouldResetState {
resetAppState()
}
if skipOnboarding {
GroupUserDefaults.groupDefaults.set(false, forKey: UserDefaultsStore.Keys.needsOnboarding.rawValue)
}
if expireTrial {
// Set firstLaunchDate to 31 days ago so the 30-day trial is expired.
// Must run BEFORE IAPManager.shared is accessed so the async status
// check sees the expired date.
let expiredDate = Calendar.current.date(byAdding: .day, value: -31, to: Date())!
GroupUserDefaults.groupDefaults.set(expiredDate, forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue)
GroupUserDefaults.groupDefaults.synchronize()
}
#if DEBUG
IAPManager.shared.bypassSubscription = bypassSubscription
// Reset subscription state to discard stale cached state from previous test runs.
// IAPManager.shared was already initialized (as @StateObject in ReflectApp) before
// configureIfNeeded runs, so it may have restored stale subscription data.
IAPManager.shared.resetForTesting()
#endif
// Seed fixture data if requested
if let fixture = seedFixture {
seedData(fixture: fixture)
}
}
/// Reset all user defaults and persisted state for a clean test run
@MainActor
private static func resetAppState() {
let defaults = GroupUserDefaults.groupDefaults
// Clear group user defaults using the session-specific or shared suite domain name
let suiteName = GroupUserDefaults.currentSuiteName
defaults.removePersistentDomain(forName: suiteName)
// Explicitly clear subscription cache keys that may survive removePersistentDomain
// on app group suites (known reliability issue).
defaults.removeObject(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue)
defaults.removeObject(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
defaults.removeObject(forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue)
// Reset key defaults explicitly (true = fresh install state where onboarding is needed)
defaults.set(true, forKey: UserDefaultsStore.Keys.needsOnboarding.rawValue)
defaults.set(0, forKey: UserDefaultsStore.Keys.votingLayoutStyle.rawValue) // horizontal
defaults.synchronize()
// Clear standard user defaults
UserDefaults.standard.set(false, forKey: "debug_bypassSubscription")
// Clear all mood data
DataController.shared.clearDB()
}
/// Seed the database with fixture data for deterministic tests
@MainActor
private static func seedData(fixture: String) {
switch fixture {
case "single_mood":
// One entry for today with mood "Great"
DataController.shared.add(mood: .great, forDate: Calendar.current.startOfDay(for: Date()), entryType: .listView)
case "week_of_moods":
// One mood per day for the last 7 days
let moods: [Mood] = [.great, .good, .average, .bad, .horrible, .good, .great]
for (offset, mood) in moods.enumerated() {
let date = Calendar.current.date(byAdding: .day, value: -offset, to: Calendar.current.startOfDay(for: Date()))!
DataController.shared.add(mood: mood, forDate: date, entryType: .listView)
}
case "empty":
// No data already cleared in resetAppState
break
default:
break
}
}
}