// // WaitHelpers.swift // Tests iOS // // Centralized, explicit wait helpers. No sleep() allowed. // import XCTest enum UITestID { enum Tab { static let day = "tab_day" static let month = "tab_month" static let year = "tab_year" static let insights = "tab_insights" static let settings = "tab_settings" } enum Day { static let moodHeader = "mood_header" static let entryRowPrefix = "entry_row_" static let sectionPrefix = "day_section_" static let emptyStateNoData = "empty_state_no_data" } enum Settings { static let header = "settings_header" static let customizeTab = "settings_tab_customize" static let settingsTab = "settings_tab_settings" static let upgradeBanner = "upgrade_banner" static let subscribeButton = "subscribe_button" static let whyUpgradeButton = "why_upgrade_button" static let browseThemesButton = "browse_themes_button" static let clearDataButton = "settings_clear_data" static let analyticsToggle = "settings_analytics_toggle" static let bypassSubscriptionToggle = "settings_bypass_subscription" static let eulaButton = "settings_eula" static let privacyPolicyButton = "settings_privacy_policy" } enum Customize { static func themeButton(_ name: String) -> String { "customize_theme_\(name.lowercased())" } static func votingLayoutButton(_ name: String) -> String { "customize_voting_\(name.lowercased())" } static func dayStyleButton(_ name: String) -> String { "customize_daystyle_\(name.lowercased())" } static func iconPackButton(_ name: String) -> String { "customize_iconpack_\(name.lowercased())" } static func personalityPackButton(_ name: String) -> String { "customize_personality_\(name.lowercased())" } static func appThemeCard(_ name: String) -> String { "apptheme_card_\(name.lowercased())" } static let pickerDoneButton = "apptheme_picker_done" static let previewCancelButton = "apptheme_preview_cancel" static let previewApplyButton = "apptheme_preview_apply" } enum EntryDetail { static let sheet = "entry_detail_sheet" static let doneButton = "entry_detail_done" static let deleteButton = "entry_detail_delete" static let noteButton = "entry_detail_note_button" static let noteArea = "entry_detail_note_area" } enum NoteEditor { static let text = "note_editor_text" static let save = "note_editor_save" static let cancel = "note_editor_cancel" } enum Onboarding { static let welcome = "onboarding_welcome" static let time = "onboarding_time" static let day = "onboarding_day" static let dayToday = "onboarding_day_today" static let dayYesterday = "onboarding_day_yesterday" static let style = "onboarding_style" static let subscription = "onboarding_subscription" static let subscribe = "onboarding_subscribe_button" static let skip = "onboarding_skip_button" } enum Paywall { static let monthOverlay = "paywall_month_overlay" static let yearOverlay = "paywall_year_overlay" static let insightsOverlay = "paywall_insights_overlay" } enum Insights { static let header = "insights_header" static let monthSection = "insights_month_section" static let yearSection = "insights_year_section" static let allTimeSection = "insights_all_time_section" } enum Year { static let heatmap = "year_heatmap" static let donutChart = "year_donut_chart" static let barChart = "year_bar_chart" static let statsSection = "year_stats_section" static func cardHeader(year: Int) -> String { "year_card_header_\(year)" } static let shareButton = "year_share_button" } enum Month { static let grid = "month_grid" static let shareButton = "month_share_button" } } extension XCUIElement { /// Wait for the element to exist in the hierarchy. /// - Parameters: /// - timeout: Maximum seconds to wait. /// - message: Custom failure message. /// - Returns: `true` if the element exists within the timeout. @discardableResult func waitForExistence(timeout: TimeInterval = 5, message: String? = nil) -> Bool { let result = waitForExistence(timeout: timeout) if !result, let message = message { XCTFail(message) } return result } /// Wait until the element is hittable (exists and is enabled/visible). /// - Parameter timeout: Maximum seconds to wait. @discardableResult func waitUntilHittable(timeout: TimeInterval = 5) -> Bool { let predicate = NSPredicate(format: "isHittable == true") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) let result = XCTWaiter.wait(for: [expectation], timeout: timeout) return result == .completed } /// Tap the element after waiting for it to become hittable. /// - Parameter timeout: Maximum seconds to wait before tapping. func tapWhenReady(timeout: TimeInterval = 5, file: StaticString = #file, line: UInt = #line) { guard waitForExistence(timeout: timeout) else { XCTFail("Element \(identifier) not found after \(timeout)s", file: file, line: line) return } if isHittable { tap() return } // Coordinate tap fallback for iOS 26 overlays where XCUI reports false-negative hittability. coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } /// Wait for the element to disappear from the hierarchy. /// - Parameter timeout: Maximum seconds to wait. @discardableResult func waitForDisappearance(timeout: TimeInterval = 5) -> Bool { let predicate = NSPredicate(format: "exists == false") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) let result = XCTWaiter.wait(for: [expectation], timeout: timeout) return result == .completed } } extension XCUIApplication { /// Find any element matching an accessibility identifier. func element(_ identifier: String) -> XCUIElement { let element = descendants(matching: .any).matching(identifier: identifier).firstMatch return element } /// Wait for any element matching the identifier to exist. func waitForElement(identifier: String, timeout: TimeInterval = 5) -> XCUIElement { let element = element(identifier) _ = element.waitForExistence(timeout: timeout) return element } var entryRows: XCUIElementQuery { descendants(matching: .any).matching(NSPredicate(format: "identifier BEGINSWITH %@", UITestID.Day.entryRowPrefix)) } var firstEntryRow: XCUIElement { entryRows.firstMatch } func tapTab(identifier: String, labels: [String], timeout: TimeInterval = 5, file: StaticString = #file, line: UInt = #line) { let idMatch = tabBars.buttons[identifier] if idMatch.waitForExistence(timeout: 1) { idMatch.tapWhenReady(timeout: timeout, file: file, line: line) return } for label in labels { let labelMatch = tabBars.buttons[label] if labelMatch.waitForExistence(timeout: 1) { labelMatch.tapWhenReady(timeout: timeout, file: file, line: line) return } } XCTFail("Unable to find tab by id \(identifier) or labels \(labels)", file: file, line: line) } @discardableResult func swipeUntilExists( _ element: XCUIElement, direction: SwipeDirection = .up, maxSwipes: Int = 6, timeoutPerTry: TimeInterval = 0.6 ) -> Bool { if element.waitForExistence(timeout: timeoutPerTry) { return true } for _ in 0..