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>
149 lines
5.5 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|