feat: add XCUITest suite with 10 critical flow tests and QA test plan
Add comprehensive UI test infrastructure with Page Object pattern, accessibility identifiers, UI test mode (--ui-testing, --reset-state, --disable-animations), and 10 passing tests covering app launch, tab navigation, trip wizard, trip saving, settings, schedule, and accessibility at XXXL Dynamic Type. Also adds a 229-case QA test plan Excel workbook for manual QA handoff. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,11 +61,28 @@ enum DemoConfig {
|
||||
static let demoTripIndex: Int = 3
|
||||
}
|
||||
|
||||
// MARK: - Demo Mode Launch Argument
|
||||
// MARK: - Launch Arguments
|
||||
|
||||
extension ProcessInfo {
|
||||
/// Check if app was launched in demo mode
|
||||
static var isDemoMode: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("-DemoMode")
|
||||
}
|
||||
|
||||
/// Check if app was launched for UI testing.
|
||||
/// When true, the app suppresses non-essential popups, disables analytics
|
||||
/// and CloudKit sync, forces Pro mode, and disables animations.
|
||||
static var isUITesting: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("--ui-testing")
|
||||
}
|
||||
|
||||
/// Check if state should be reset before the test run.
|
||||
static var shouldResetState: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("--reset-state")
|
||||
}
|
||||
|
||||
/// Check if animations should be disabled.
|
||||
static var shouldDisableAnimations: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("--disable-animations")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ struct HomeView: View {
|
||||
Label("Home", systemImage: "house.fill")
|
||||
}
|
||||
.tag(0)
|
||||
.accessibilityIdentifier("tab.home")
|
||||
|
||||
// Schedule Tab
|
||||
NavigationStack {
|
||||
@@ -61,6 +62,7 @@ struct HomeView: View {
|
||||
Label("Schedule", systemImage: "calendar")
|
||||
}
|
||||
.tag(1)
|
||||
.accessibilityIdentifier("tab.schedule")
|
||||
|
||||
// My Trips Tab
|
||||
NavigationStack {
|
||||
@@ -70,6 +72,7 @@ struct HomeView: View {
|
||||
Label("My Trips", systemImage: "suitcase.fill")
|
||||
}
|
||||
.tag(2)
|
||||
.accessibilityIdentifier("tab.myTrips")
|
||||
|
||||
// Progress Tab
|
||||
NavigationStack {
|
||||
@@ -85,6 +88,7 @@ struct HomeView: View {
|
||||
Label("Progress", systemImage: "chart.bar.fill")
|
||||
}
|
||||
.tag(3)
|
||||
.accessibilityIdentifier("tab.progress")
|
||||
|
||||
// Settings Tab
|
||||
NavigationStack {
|
||||
@@ -94,6 +98,7 @@ struct HomeView: View {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
.tag(4)
|
||||
.accessibilityIdentifier("tab.settings")
|
||||
}
|
||||
.tint(Theme.warmOrange)
|
||||
.onChange(of: selectedTab) { oldTab, newTab in
|
||||
@@ -633,6 +638,7 @@ struct SavedTripsListView: View {
|
||||
.padding(Theme.Spacing.xl)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.accessibilityIdentifier("myTrips.emptyState")
|
||||
} else {
|
||||
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
|
||||
if let trip = savedTrip.trip {
|
||||
|
||||
@@ -82,6 +82,7 @@ struct HomeContent_ClassicAnimated: View {
|
||||
}
|
||||
.pressableStyle()
|
||||
.glowEffect(color: Theme.warmOrange, radius: 12)
|
||||
.accessibilityIdentifier("home.startPlanningButton")
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
|
||||
@@ -317,6 +317,7 @@ struct SettingsView: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("settings.analyticsToggle")
|
||||
} header: {
|
||||
Text("Privacy")
|
||||
} footer: {
|
||||
@@ -334,6 +335,7 @@ struct SettingsView: View {
|
||||
Spacer()
|
||||
Text("\(viewModel.appVersion) (\(viewModel.buildNumber))")
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("settings.versionLabel")
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "https://sportstime.88oakapps.com/privacy.html")!) {
|
||||
|
||||
@@ -19,6 +19,15 @@ struct SportsTimeApp: App {
|
||||
private var transactionListener: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
// UI Test Mode: disable animations and force classic style for deterministic tests
|
||||
if ProcessInfo.isUITesting || ProcessInfo.shouldDisableAnimations {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
}
|
||||
if ProcessInfo.isUITesting {
|
||||
// Force classic (non-animated) home variant for consistent identifiers
|
||||
DesignStyleManager.shared.setStyle(.classic)
|
||||
}
|
||||
|
||||
// Configure sync manager immediately so push/background triggers can sync.
|
||||
BackgroundSyncManager.shared.configure(with: sharedModelContainer)
|
||||
|
||||
@@ -27,7 +36,9 @@ struct SportsTimeApp: App {
|
||||
BackgroundSyncManager.shared.registerTasks()
|
||||
|
||||
// Start listening for transactions immediately
|
||||
transactionListener = StoreManager.shared.listenForTransactions()
|
||||
if !ProcessInfo.isUITesting {
|
||||
transactionListener = StoreManager.shared.listenForTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
@@ -93,7 +104,8 @@ struct BootstrappedContentView: View {
|
||||
@State private var appearanceManager = AppearanceManager.shared
|
||||
|
||||
private var shouldShowOnboardingPaywall: Bool {
|
||||
!UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
|
||||
guard !ProcessInfo.isUITesting else { return false }
|
||||
return !UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -136,6 +148,7 @@ struct BootstrappedContentView: View {
|
||||
deepLinkHandler.handleURL(url)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
guard !ProcessInfo.isUITesting else { return }
|
||||
switch newPhase {
|
||||
case .active:
|
||||
// Refresh super properties (subscription status may have changed)
|
||||
@@ -170,6 +183,18 @@ struct BootstrappedContentView: View {
|
||||
let bootstrapService = BootstrapService()
|
||||
|
||||
do {
|
||||
// 0. UI Test Mode: reset user data if requested
|
||||
if ProcessInfo.shouldResetState {
|
||||
print("🚀 [BOOT] Step 0: Resetting user data for UI tests...")
|
||||
try context.delete(model: SavedTrip.self)
|
||||
try context.delete(model: StadiumVisit.self)
|
||||
try context.delete(model: Achievement.self)
|
||||
try context.delete(model: LocalTripPoll.self)
|
||||
try context.delete(model: LocalPollVote.self)
|
||||
try context.delete(model: TripVote.self)
|
||||
try context.save()
|
||||
}
|
||||
|
||||
// 1. Bootstrap from bundled JSON if first launch (no data exists)
|
||||
print("🚀 [BOOT] Step 1: Checking if bootstrap needed...")
|
||||
try await bootstrapService.bootstrapIfNeeded(context: context)
|
||||
@@ -192,40 +217,61 @@ struct BootstrappedContentView: View {
|
||||
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums")
|
||||
|
||||
// 5. Load store products and entitlements
|
||||
print("🚀 [BOOT] Step 5: Loading store products...")
|
||||
await StoreManager.shared.loadProducts()
|
||||
await StoreManager.shared.updateEntitlements()
|
||||
if ProcessInfo.isUITesting {
|
||||
print("🚀 [BOOT] Step 5: UI Test Mode — forcing Pro, skipping StoreKit")
|
||||
#if DEBUG
|
||||
StoreManager.shared.debugProOverride = true
|
||||
#endif
|
||||
} else {
|
||||
print("🚀 [BOOT] Step 5: Loading store products...")
|
||||
await StoreManager.shared.loadProducts()
|
||||
await StoreManager.shared.updateEntitlements()
|
||||
}
|
||||
|
||||
// 6. Start network monitoring and wire up sync callback
|
||||
print("🚀 [BOOT] Step 6: Starting network monitoring...")
|
||||
NetworkMonitor.shared.onSyncNeeded = {
|
||||
await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
|
||||
if !ProcessInfo.isUITesting {
|
||||
print("🚀 [BOOT] Step 6: Starting network monitoring...")
|
||||
NetworkMonitor.shared.onSyncNeeded = {
|
||||
await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
|
||||
}
|
||||
NetworkMonitor.shared.startMonitoring()
|
||||
} else {
|
||||
print("🚀 [BOOT] Step 6: UI Test Mode — skipping network monitoring")
|
||||
}
|
||||
NetworkMonitor.shared.startMonitoring()
|
||||
|
||||
// 7. Configure analytics
|
||||
print("🚀 [BOOT] Step 7: Configuring analytics...")
|
||||
AnalyticsManager.shared.configure()
|
||||
if !ProcessInfo.isUITesting {
|
||||
print("🚀 [BOOT] Step 7: Configuring analytics...")
|
||||
AnalyticsManager.shared.configure()
|
||||
} else {
|
||||
print("🚀 [BOOT] Step 7: UI Test Mode — skipping analytics")
|
||||
}
|
||||
|
||||
// 8. App is now usable
|
||||
print("🚀 [BOOT] Step 8: Bootstrap complete - app ready")
|
||||
isBootstrapping = false
|
||||
|
||||
// 9. Schedule background tasks for future syncs
|
||||
BackgroundSyncManager.shared.scheduleAllTasks()
|
||||
// 9-10: Background sync (skip in UI test mode)
|
||||
if !ProcessInfo.isUITesting {
|
||||
// 9. Schedule background tasks for future syncs
|
||||
BackgroundSyncManager.shared.scheduleAllTasks()
|
||||
|
||||
// 9b. Ensure CloudKit subscriptions exist for push-driven sync.
|
||||
Task(priority: .utility) {
|
||||
await BackgroundSyncManager.shared.ensureCanonicalSubscriptions()
|
||||
}
|
||||
|
||||
// 10. Background: Try to refresh from CloudKit (non-blocking)
|
||||
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
|
||||
Task(priority: .background) {
|
||||
await self.performBackgroundSync(context: self.modelContainer.mainContext)
|
||||
await MainActor.run {
|
||||
self.hasCompletedInitialSync = true
|
||||
// 9b. Ensure CloudKit subscriptions exist for push-driven sync.
|
||||
Task(priority: .utility) {
|
||||
await BackgroundSyncManager.shared.ensureCanonicalSubscriptions()
|
||||
}
|
||||
|
||||
// 10. Background: Try to refresh from CloudKit (non-blocking)
|
||||
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
|
||||
Task(priority: .background) {
|
||||
await self.performBackgroundSync(context: self.modelContainer.mainContext)
|
||||
await MainActor.run {
|
||||
self.hasCompletedInitialSync = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("🚀 [BOOT] Steps 9-10: UI Test Mode — skipping CloudKit sync")
|
||||
hasCompletedInitialSync = true
|
||||
}
|
||||
} catch {
|
||||
print("❌ [BOOT] Bootstrap failed: \(error.localizedDescription)")
|
||||
|
||||
Reference in New Issue
Block a user