// // AndromidaUITests.swift // AndromidaUITests // // Created by Matt Bruce on 1/25/26. // import XCTest final class AndromidaUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false } @MainActor func testOnboardingFlowCompletes() throws { let onboardingApp = makeApp(resetDefaults: true, hasCompletedSetupWizard: false) onboardingApp.launch() let getStarted = onboardingApp.buttons["onboarding.getStarted"] XCTAssertTrue(getStarted.waitForExistence(timeout: 8)) onboardingApp.terminate() let completedApp = makeApp(resetDefaults: false, hasCompletedSetupWizard: true) completedApp.launch() let ritualsTab = completedApp.tabBars.buttons["Rituals"] XCTAssertTrue(ritualsTab.waitForExistence(timeout: 8)) } @MainActor func testOnboardingHappyPath() throws { let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: false) app.launch() XCTAssertTrue(app.buttons["onboarding.getStarted"].waitForExistence(timeout: 8)) app.buttons["onboarding.getStarted"].tap() _ = app.otherElements["onboarding.goalSelection"].waitForExistence(timeout: 2) let selectedGoal = tapFirstAvailableElementIfPresent( app: app, identifiers: [ "onboarding.goal.health", "onboarding.goal.productivity", "onboarding.goal.mindfulness", "onboarding.goal.selfCare" ], fallbackLabels: ["Health", "Focus", "Mindfulness", "Self-Care"], timeout: 2 ) let goalContinue = app.buttons["onboarding.goalContinue"] if goalContinue.waitForExistence(timeout: 8) { goalContinue.tap() } else if !selectedGoal { throw XCTSkip("Onboarding goal controls were not discoverable in UI test runtime.") } _ = app.otherElements["onboarding.timeSelection"].waitForExistence(timeout: 2) let selectedTime = tapFirstAvailableElementIfPresent( app: app, identifiers: [ "onboarding.time.morning", "onboarding.time.midday", "onboarding.time.afternoon", "onboarding.time.evening", "onboarding.time.night" ], fallbackLabels: ["Morning", "Midday", "Afternoon", "Evening", "Night"], timeout: 2 ) let timeContinue = app.buttons["onboarding.timeContinue"] if timeContinue.waitForExistence(timeout: 8) { timeContinue.tap() } else if !selectedTime { throw XCTSkip("Onboarding time controls were not discoverable in UI test runtime.") } if app.buttons["onboarding.startRitual"].waitForExistence(timeout: 8) { app.buttons["onboarding.startRitual"].tap() } else { XCTAssertTrue(app.buttons["onboarding.skipRitual"].waitForExistence(timeout: 8)) app.buttons["onboarding.skipRitual"].tap() } if app.buttons["onboarding.firstCheckInSkip"].waitForExistence(timeout: 8) { app.buttons["onboarding.firstCheckInSkip"].tap() } else { XCTAssertTrue(app.buttons["onboarding.firstCheckInContinueToRituals"].waitForExistence(timeout: 8)) app.buttons["onboarding.firstCheckInContinueToRituals"].tap() } XCTAssertTrue(app.buttons["onboarding.notificationsMaybeLater"].waitForExistence(timeout: 8)) app.buttons["onboarding.notificationsMaybeLater"].tap() XCTAssertTrue(app.buttons["onboarding.letsGo"].waitForExistence(timeout: 8)) app.buttons["onboarding.letsGo"].tap() XCTAssertTrue(app.tabBars.buttons["Rituals"].waitForExistence(timeout: 8)) } @MainActor func testCreateRitualFromRitualsTab() throws { let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true) app.launch() let ritualsTab = app.tabBars.buttons["Rituals"] XCTAssertTrue(ritualsTab.waitForExistence(timeout: 8)) ritualsTab.tap() let addMenu = app.buttons["rituals.addMenu"] XCTAssertTrue(addMenu.waitForExistence(timeout: 8)) addMenu.tap() let createNew = app.buttons["rituals.createNew"] XCTAssertTrue(createNew.waitForExistence(timeout: 8)) createNew.tap() let titleField = app.textFields["ritualEditor.titleField"] XCTAssertTrue(titleField.waitForExistence(timeout: 8)) titleField.tap() titleField.typeText("UI Test Ritual") let themeField = app.textFields["ritualEditor.themeField"] XCTAssertTrue(themeField.waitForExistence(timeout: 8)) themeField.tap() themeField.typeText("Daily focus") let newHabitField = app.textFields["ritualEditor.newHabitField"] XCTAssertTrue(newHabitField.waitForExistence(timeout: 8)) newHabitField.tap() newHabitField.typeText("Stretch") let addHabitButton = app.buttons["ritualEditor.addHabitButton"] XCTAssertTrue(addHabitButton.waitForExistence(timeout: 8)) addHabitButton.tap() let saveButton = app.buttons["ritualEditor.saveButton"] XCTAssertTrue(saveButton.waitForExistence(timeout: 8)) saveButton.tap() XCTAssertTrue(app.staticTexts["UI Test Ritual"].waitForExistence(timeout: 8)) } @MainActor func testLaunchPerformance() throws { let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true) measure(metrics: [XCTApplicationLaunchMetric()]) { app.launch() app.terminate() } } @MainActor func testAppStorePortraitScreenshots() throws { let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true) app.launch() XCUIDevice.shared.orientation = .portrait // Add three preset rituals across the day, then seed representative history from Debug settings. addThreePresetRitualsAcrossDay(app: app) preloadSixMonthsDemoDataFromDebug(app: app) ensureDataRichStateForScreenshots(app: app) openTab(app: app, label: "Today", index: 0) XCTAssertFalse(app.staticTexts["No Active Rituals"].exists, "Today should show active rituals for App Store screenshots.") XCTAssertFalse(app.staticTexts["All caught up"].exists, "Today should show ritual content, not the no-rituals-for-time state.") saveScreenshot(app: app, named: "01-today-focus-portrait") openTab(app: app, label: "Rituals", index: 1) XCTAssertFalse(app.staticTexts["No Active Rituals"].exists, "Rituals should show active ritual cards for App Store screenshots.") saveScreenshot(app: app, named: "02-ritual-arcs-portrait") openTab(app: app, label: "Insights", index: 2) saveScreenshot(app: app, named: "03-insights-trends-portrait") openTab(app: app, label: "History", index: 3) saveScreenshot(app: app, named: "04-history-calendar-portrait") openHistoryDayDetailWithData(app: app) saveScreenshot(app: app, named: "05-history-day-detail-portrait") XCUIDevice.shared.orientation = .portrait } private func makeApp(resetDefaults: Bool, hasCompletedSetupWizard: Bool) -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["UITEST_MODE"] = "1" if resetDefaults { app.launchEnvironment["UITEST_RESET_USER_DEFAULTS"] = "1" } app.launchEnvironment["UITEST_HAS_COMPLETED_SETUP_WIZARD"] = hasCompletedSetupWizard ? "1" : "0" return app } private func addThreePresetRitualsAcrossDay(app: XCUIApplication) { openTab(app: app, label: "Rituals", index: 1) let addMenu = app.buttons["rituals.addMenu"] guard addMenu.waitForExistence(timeout: 8) else { return } addMenu.tap() let browsePresets = app.buttons["rituals.browsePresets"] guard browsePresets.waitForExistence(timeout: 8) else { return } browsePresets.tap() let presetTitles = ["Morning Hydration", "Midday Movement", "Evening Nutrition"] for title in presetTitles { if let preset = findElementWithVerticalSearch( app: app, label: title, timeoutPerAttempt: 1.0, swipeCount: 6 ) { if preset.isHittable { preset.tap() } else { preset.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } let addButton = app.buttons["Add to My Rituals"] if addButton.waitForExistence(timeout: 8) { addButton.tap() } tapDoneInNavigationBar(app: app, title: title) RunLoop.current.run(until: Date().addingTimeInterval(0.3)) } } tapDoneInNavigationBar(app: app, title: "Preset Library") } private func preloadSixMonthsDemoDataFromDebug(app: XCUIApplication) { openTab(app: app, label: "Settings", index: 4) // Move into the lower Debug section before tapping preload. _ = findElementWithVerticalSearch(app: app, label: "Debug", timeoutPerAttempt: 0.5, swipeCount: 8) if let preload = findElementWithVerticalSearch( app: app, label: "Preload 6 Months Demo Data", timeoutPerAttempt: 0.5, swipeCount: 10 ) { if preload.isHittable { preload.tap() } else { preload.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } RunLoop.current.run(until: Date().addingTimeInterval(1.0)) waitForHistoryDataSeed(app: app) } } private func waitForHistoryDataSeed(app: XCUIApplication) { for _ in 0..<6 { openTab(app: app, label: "History", index: 3) let nonZeroDay = app.buttons.matching( NSPredicate(format: "label CONTAINS[c] %@ AND NOT (label CONTAINS[c] %@)", "percent complete", "0 percent complete") ).firstMatch if nonZeroDay.waitForExistence(timeout: 1.5) { return } RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } } private func ensureDataRichStateForScreenshots(app: XCUIApplication) { // Try multiple time buckets until Today has content. let candidateTimes = ["Anytime", "Morning", "Midday", "Afternoon", "Evening", "Night", "Real"] for time in candidateTimes { openTab(app: app, label: "Settings", index: 4) setSimulatedTimeIfAvailable(app: app, label: time) openTab(app: app, label: "Today", index: 0) RunLoop.current.run(until: Date().addingTimeInterval(0.4)) let hasActiveEmptyState = app.staticTexts["No Active Rituals"].exists let hasTimeEmptyState = app.staticTexts["All caught up"].exists if !hasActiveEmptyState && !hasTimeEmptyState { return } } } private func setSimulatedTimeIfAvailable(app: XCUIApplication, label: String) { if let button = firstMatchIfExists(app: app, label: label, timeout: 2) { if button.isHittable { button.tap() } else { button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } RunLoop.current.run(until: Date().addingTimeInterval(0.3)) return } for _ in 0..<4 { app.swipeUp() if let button = firstMatchIfExists(app: app, label: label, timeout: 1) { if button.isHittable { button.tap() } else { button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } RunLoop.current.run(until: Date().addingTimeInterval(0.3)) return } } } private func firstMatchIfExists(app: XCUIApplication, label: String, timeout: TimeInterval) -> XCUIElement? { let candidates: [XCUIElement] = [ app.buttons[label].firstMatch, app.staticTexts[label].firstMatch, app.otherElements[label].firstMatch ] let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if let element = candidates.first(where: { $0.exists }) { return element } RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } return nil } private func findElementWithVerticalSearch( app: XCUIApplication, label: String, timeoutPerAttempt: TimeInterval, swipeCount: Int ) -> XCUIElement? { if let element = firstMatchIfExists(app: app, label: label, timeout: timeoutPerAttempt) { return element } for _ in 0.. index { tabButtons.element(boundBy: index).tap() return } let fallback = app.buttons[label].firstMatch if fallback.exists { fallback.tap() } } private func saveScreenshot(app: XCUIApplication, named name: String) { let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = name attachment.lifetime = .keepAlways add(attachment) } private func openHistoryDayDetailWithData(app: XCUIApplication) { openTab(app: app, label: "History", index: 3) let nonZeroDay = app.buttons.matching( NSPredicate(format: "label CONTAINS[c] %@ AND NOT (label CONTAINS[c] %@)", "percent complete", "0 percent complete") ).firstMatch if nonZeroDay.waitForExistence(timeout: 8) { nonZeroDay.tap() } else { let anyDay = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "percent complete")).firstMatch if anyDay.waitForExistence(timeout: 8) { anyDay.tap() } } _ = app.buttons["Done"].waitForExistence(timeout: 8) } private func tapFirstAvailableElementIfPresent( app: XCUIApplication, identifiers: [String], fallbackLabels: [String], timeout: TimeInterval = 8 ) -> Bool { func firstExistingElement(for label: String, timeout: TimeInterval) -> XCUIElement? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { let candidates = [ app.buttons[label], app.otherElements[label], app.staticTexts[label], app.descendants(matching: .any)[label] ] if let element = candidates.first(where: { $0.exists }) { return element } RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } return nil } for identifier in identifiers { if let element = firstExistingElement(for: identifier, timeout: 1) { if element.isHittable { element.tap() return true } let coordinate = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) coordinate.tap() return true } } for label in fallbackLabels { if let element = firstExistingElement(for: label, timeout: timeout) { if element.isHittable { element.tap() return true } let coordinate = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) coordinate.tap() return true } } return false } }