Files
Reflect/Shared/Persisence/SharedModelContainer.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

143 lines
5.7 KiB
Swift

//
// SharedModelContainer.swift
// Reflect
//
// Factory for creating ModelContainer shared between main app and widget extension.
//
import Foundation
import SwiftData
import os.log
/// Errors that can occur when creating the shared model container
enum SharedModelContainerError: LocalizedError {
case appGroupNotAvailable(String)
case modelContainerCreationFailed(Error)
var errorDescription: String? {
switch self {
case .appGroupNotAvailable(let groupID):
return "App Group container not available for: \(groupID)"
case .modelContainerCreationFailed(let error):
return "Failed to create ModelContainer: \(error.localizedDescription)"
}
}
}
enum SharedModelContainer {
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect", category: "SharedModelContainer")
/// Indicates whether the app is running with in-memory storage due to a failed App Group container.
/// When `true`, all data will be lost on app restart.
static private(set) var isUsingInMemoryFallback = false
/// Creates a ModelContainer with the appropriate configuration for app group sharing
/// - Parameter useCloudKit: Whether to enable CloudKit sync (defaults to true)
/// - Returns: Configured ModelContainer
/// - Throws: SharedModelContainerError if creation fails
static func create(useCloudKit: Bool = true) throws -> ModelContainer {
// When UI testing, use in-memory storage for parallel test isolation.
// Each test process gets its own empty container no shared on-disk state.
// Check ProcessInfo directly to avoid depending on UITestMode (not in widget targets).
if ProcessInfo.processInfo.arguments.contains("--ui-testing") {
let schema = Schema([MoodEntryModel.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
return try ModelContainer(for: schema, configurations: [config])
}
let schema = Schema([MoodEntryModel.self])
let storeURL = try Self.storeURL
let configuration: ModelConfiguration
if useCloudKit {
// CloudKit-enabled configuration
configuration = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .private(cloudKitContainerID)
)
} else {
// Local-only configuration
configuration = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .none
)
}
do {
return try ModelContainer(for: schema, configurations: [configuration])
} catch {
logger.error("Failed to create ModelContainer: \(error.localizedDescription)")
throw SharedModelContainerError.modelContainerCreationFailed(error)
}
}
/// Creates a ModelContainer, falling back to in-memory storage if shared container fails
/// - Parameter useCloudKit: Whether to enable CloudKit sync (defaults to true)
/// - Returns: Configured ModelContainer (shared or in-memory fallback)
static func createWithFallback(useCloudKit: Bool = true) -> ModelContainer {
do {
return try create(useCloudKit: useCloudKit)
} catch {
logger.warning("Falling back to in-memory storage due to: \(error.localizedDescription)")
logger.critical("App is using in-memory storage — all mood data will be lost on restart. App Group container failed: \(error.localizedDescription)")
#if DEBUG
assertionFailure("SharedModelContainer fell back to in-memory storage. App Group container is unavailable: \(error.localizedDescription)")
#endif
isUsingInMemoryFallback = true
// Fall back to in-memory storage
let schema = Schema([MoodEntryModel.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
do {
return try ModelContainer(for: schema, configurations: [config])
} catch {
// This should never happen with in-memory storage, but handle it gracefully
logger.critical("Failed to create even in-memory ModelContainer: \(error.localizedDescription)")
preconditionFailure("Unable to create ModelContainer: \(error)")
}
}
}
/// The URL for the SwiftData store in the shared app group container
/// - Throws: SharedModelContainerError if app group is not available
static var storeURL: URL {
get throws {
guard let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupID
) else {
logger.error("App Group container not available for: \(appGroupID)")
throw SharedModelContainerError.appGroupNotAvailable(appGroupID)
}
return containerURL.appendingPathComponent(storeFileName)
}
}
/// App Group identifier based on build configuration
static var appGroupID: String {
#if DEBUG
return Constants.groupShareIdDebug
#else
return Constants.groupShareId
#endif
}
/// CloudKit container identifier based on build configuration
static var cloudKitContainerID: String {
#if DEBUG
return "iCloud.com.88oakapps.reflect.debug"
#else
return "iCloud.com.88oakapps.reflect"
#endif
}
/// Store file name based on build configuration
static var storeFileName: String {
#if DEBUG
return "Reflect-Debug.store"
#else
return "Reflect.store"
#endif
}
}