import XCTest /// Base class for tests requiring a logged-in session against the real local backend. /// /// By default, creates a fresh verified account via the API, launches the app /// (without `--ui-test-mock-auth`), and drives the UI through login. /// /// Override `useSeededAccount` to log in with a pre-existing database account instead. /// Override `performUILogin` to skip the UI login step (if you only need the API session). /// /// ## Data Seeding & Cleanup /// Use the `cleaner` property to seed data that auto-cleans in tearDown: /// ``` /// let residence = cleaner.seedResidence(name: "My Test Home") /// let task = cleaner.seedTask(residenceId: residence.id) /// ``` /// Or seed without tracking via `TestDataSeeder` and track manually: /// ``` /// let res = TestDataSeeder.createResidence(token: session.token) /// cleaner.trackResidence(res.id) /// ``` class AuthenticatedTestCase: BaseUITestCase { /// The active test session, populated during setUp. var session: TestSession! /// Tracks and cleans up resources created during the test. /// Initialized in setUp after the session is established. private(set) var cleaner: TestDataCleaner! /// Override to `true` in subclasses that should use the pre-seeded admin account. var useSeededAccount: Bool { false } /// Seeded account credentials. Override in subclasses that use a different seeded user. var seededUsername: String { "admin" } var seededPassword: String { "test1234" } /// Override to `false` to skip driving the app through the login UI. var performUILogin: Bool { true } /// Skip onboarding so the app goes straight to the login screen. override var completeOnboarding: Bool { true } /// Don't reset state — DataManager.shared.clear() during app init triggers /// a Kotlin/Native SIGKILL crash on the simulator. Since we use the seeded /// admin account and loginViaUI() handles persisted sessions, this is safe. override var includeResetStateLaunchArgument: Bool { false } /// No mock auth - we're testing against the real backend. override var additionalLaunchArguments: [String] { [] } // MARK: - Setup override func setUpWithError() throws { // Check backend reachability before anything else guard TestAccountAPIClient.isBackendReachable() else { throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)") } // Create or login account via API if useSeededAccount { guard let s = TestAccountManager.loginSeededAccount( username: seededUsername, password: seededPassword ) else { throw XCTSkip("Could not login seeded account '\(seededUsername)'") } session = s } else { guard let s = TestAccountManager.createVerifiedAccount() else { throw XCTSkip("Could not create verified test account") } session = s } // Initialize the cleaner with the session token cleaner = TestDataCleaner(token: session.token) // Launch the app (calls BaseUITestCase.setUpWithError which launches and waits for ready) try super.setUpWithError() // Tap somewhere on the app to trigger any pending interruption monitors // (BaseUITestCase already adds an addUIInterruptionMonitor in setUp) app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() sleep(1) // Drive the UI through login if needed if performUILogin { loginViaUI() } } override func tearDownWithError() throws { // Clean up all tracked test data cleaner?.cleanAll() try super.tearDownWithError() } // MARK: - UI Login /// Navigate to login screen → type credentials → wait for main tabs. func loginViaUI() { // If already on main tabs (persisted session from previous test), skip login. let mainTabs = app.otherElements[UITestID.Root.mainTabs] let tabBar = app.tabBars.firstMatch if mainTabs.waitForExistence(timeout: 3) || tabBar.waitForExistence(timeout: 2) { return } // With --complete-onboarding the app should land on login directly. // Use ensureOnLoginScreen as a robust fallback that handles any state. let usernameField = app.textFields[UITestID.Auth.usernameField] if !usernameField.waitForExistence(timeout: 10) { UITestHelpers.ensureOnLoginScreen(app: app) } let login = LoginScreenObject(app: app) login.waitForLoad(timeout: defaultTimeout) login.enterUsername(session.username) login.enterPassword(session.password) // Try tapping the keyboard "Go" button first (triggers onSubmit which logs in) let goButton = app.keyboards.buttons["Go"] let returnButton = app.keyboards.buttons["Return"] if goButton.waitForExistence(timeout: 3) && goButton.isHittable { goButton.tap() } else if returnButton.exists && returnButton.isHittable { returnButton.tap() } else { // Dismiss keyboard by tapping empty area, then tap login button app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap() sleep(1) let loginButton = app.buttons[UITestID.Auth.loginButton] if loginButton.waitForExistence(timeout: defaultTimeout) { // Wait until truly hittable (not behind keyboard) let hittable = NSPredicate(format: "exists == true AND hittable == true") let exp = XCTNSPredicateExpectation(predicate: hittable, object: loginButton) _ = XCTWaiter().wait(for: [exp], timeout: 10) loginButton.forceTap() } else { XCTFail("Login button not found") } } // Wait for either main tabs or verification screen let deadline = Date().addingTimeInterval(longTimeout) var checkedForError = false while Date() < deadline { if mainTabs.exists || tabBar.exists { return } // After a few seconds, check for login error messages if !checkedForError { sleep(3) checkedForError = true // Check if we're still on the login screen (login failed) if usernameField.exists { // Look for error messages let errorTexts = app.staticTexts.allElementsBoundByIndex.filter { let label = $0.label.lowercased() return label.contains("error") || label.contains("invalid") || label.contains("failed") || label.contains("incorrect") || label.contains("not authenticated") || label.contains("wrong") } if !errorTexts.isEmpty { let errorMsg = errorTexts.map { $0.label }.joined(separator: ", ") XCTFail("Login failed with error: \(errorMsg)") return } // No error visible but still on login — try tapping login again let retryLoginButton = app.buttons[UITestID.Auth.loginButton] if retryLoginButton.exists { retryLoginButton.forceTap() } } } // Check for email verification gate - if we hit it, enter the debug code let verificationScreen = VerificationScreen(app: app) if verificationScreen.codeField.exists { verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode) verificationScreen.submitCode() // Wait for main tabs after verification if mainTabs.waitForExistence(timeout: longTimeout) || tabBar.waitForExistence(timeout: 5) { return } } RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } // Capture what's on screen for debugging let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = "LoginFailure" attachment.lifetime = .keepAlways add(attachment) let visibleTexts = app.staticTexts.allElementsBoundByIndex.prefix(15).map { $0.label } let visibleButtons = app.buttons.allElementsBoundByIndex.prefix(10).map { $0.identifier.isEmpty ? $0.label : $0.identifier } XCTFail("Failed to reach main app after login. Visible texts: \(visibleTexts). Buttons: \(visibleButtons)") } // MARK: - Tab Navigation /// Map from identifier suffix to the actual tab bar label (handles mismatches like "Documents" → "Docs") private static let tabLabelMap: [String: String] = [ "Documents": "Docs" ] func navigateToTab(_ tab: String) { // With .sidebarAdaptable tab style, there can be duplicate buttons. // Always use the tab bar's buttons directly to avoid ambiguity. let label = tab.replacingOccurrences(of: "TabBar.", with: "") // Try exact match first let tabBarButton = app.tabBars.firstMatch.buttons[label] if tabBarButton.waitForExistence(timeout: defaultTimeout) { tabBarButton.tap() // Verify the tap took effect by checking the tab is selected if !tabBarButton.waitForExistence(timeout: 2) || !tabBarButton.isSelected { // Retry - tap the app to trigger any interruption monitors, then retry app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() sleep(1) tabBarButton.tap() } return } // Try mapped label (e.g. "Documents" → "Docs") if let mappedLabel = Self.tabLabelMap[label] { let mappedButton = app.tabBars.firstMatch.buttons[mappedLabel] if mappedButton.waitForExistence(timeout: 5) { mappedButton.tap() if !mappedButton.isSelected { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() sleep(1) mappedButton.tap() } return } } // Fallback: search by partial match let byLabel = app.tabBars.firstMatch.buttons.containing( NSPredicate(format: "label CONTAINS[c] %@", label) ).firstMatch if byLabel.waitForExistence(timeout: 5) { byLabel.tap() return } XCTFail("Could not find tab '\(label)' in tab bar") } func navigateToResidences() { navigateToTab(AccessibilityIdentifiers.Navigation.residencesTab) } func navigateToTasks() { navigateToTab(AccessibilityIdentifiers.Navigation.tasksTab) } func navigateToContractors() { navigateToTab(AccessibilityIdentifiers.Navigation.contractorsTab) } func navigateToDocuments() { navigateToTab(AccessibilityIdentifiers.Navigation.documentsTab) } func navigateToProfile() { navigateToTab(AccessibilityIdentifiers.Navigation.profileTab) } // MARK: - Pull to Refresh /// Perform a pull-to-refresh gesture on the current screen's scrollable content. /// Use after navigating to a tab when data was seeded via API after login. func pullToRefresh() { // SwiftUI List/Form uses UICollectionView internally let collectionView = app.collectionViews.firstMatch let scrollView = app.scrollViews.firstMatch let listElement = collectionView.exists ? collectionView : scrollView guard listElement.waitForExistence(timeout: 5) else { return } let start = listElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)) let end = listElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) start.press(forDuration: 0.3, thenDragTo: end) sleep(3) // wait for refresh to complete } /// Perform pull-to-refresh repeatedly until a target element appears or max retries reached. func pullToRefreshUntilVisible(_ element: XCUIElement, maxRetries: Int = 3) { for _ in 0..