import XCTest /// Base class for all tests that require a logged-in user. class AuthenticatedUITestCase: BaseUITestCase { // MARK: - Configuration (override in subclasses) var testCredentials: (username: String, password: String) { ("testuser", "TestPass123!") } var needsAPISession: Bool { false } /// Authenticated suites test the post-onboarding app. A freshly-seeded user /// has no residence, so without this the app routes to the onboarding flow /// after login instead of the main tabs. Launch with --complete-onboarding /// (sets OnboardingState.hasCompletedOnboarding) so login lands on main tabs. override var completeOnboarding: Bool { true } /// Per-test isolation relaunches the app fresh for every test. With /// --reset-state this lands on the login screen, so each test logs in as its /// own fresh account WITHOUT a fragile UI logout between tests (the old /// logout-via-profile path was the #1 source of flakes under load). override var relaunchBetweenTests: Bool { true } /// In fresh-account mode the app boots already authenticated via the /// account's real Kratos token (the app reads `--ui-test-session-token` in /// UITestRuntime) — skipping the slow, flaky UI login (~8-12s/test). The /// account is created before the launch (see setUpWithError), so its token /// is available here when BaseUITestCase assembles the launch arguments. override var additionalLaunchArguments: [String] { if usesFreshAccount, let token = account?.token { return ["--ui-test-session-token", token] } return [] } /// Credentials for the Kratos APP identity used to seed data over the API. /// /// ⚠️ TWO DIFFERENT "admin@honeydue.com" EXIST — do NOT "fix" Test1234 to password123: /// (a) Kratos APP identity — admin@honeydue.com / Test1234. Created by this class's /// `setUp` (and re-seeded by SuiteZZ). Used here for API data-seeding and login. /// (b) Admin-PANEL SQL super-admin — admin@honeydue.com / password123. A separate /// system, used ONLY by SuiteZZ_CleanupTests to call /admin/settings/clear-all-data. /// They happen to share an email but are unrelated. Changing Test1234 here would break /// all API seeding; changing password123 in SuiteZZ would break the data wipe. var apiCredentials: (username: String, password: String) { ("admin", "Test1234") } // MARK: - Account isolation /// When `true` (default), each test mints its OWN unique, pre-verified /// Kratos account, logs in as it, seeds under its token, and deletes it in /// teardown — so suites are fully independent and parallel-safe. Override to /// `false` only in suites that must log in as a SPECIFIC seeded account /// (then also override `testCredentials`). var usesFreshAccount: Bool { true } /// Short slug used in generated account emails (uit__@...), /// cosmetic for debugging. Defaults to the test class name. var accountDomain: String { String(describing: type(of: self)) } /// The per-test isolated account (non-nil in fresh-account mode). private(set) var account: TestAccount? /// Set `true` in suites whose UI gates on a residence existing (e.g. task /// or document creation). Seeds one residence BEFORE login so the app loads /// it on its post-login fetch; available to the test body as `seededResidence`. var requiresResidence: Bool { false } /// The residence seeded as a precondition (when `requiresResidence`). private(set) var seededResidence: TestResidence? /// Seed baseline data the UI gates on for this test's fresh account, BEFORE /// the app logs in (a fresh account is otherwise empty, so anything seeded /// after login is invisible until a manual refresh). Override to seed a full /// scenario (residence + tasks/documents); call `super` to keep the /// `requiresResidence` convenience. func seedAccountPreconditions(_ account: TestAccount) { if requiresResidence { seededResidence = account.seedResidence(name: "Precondition Home") } } // MARK: - API Session /// The authenticated session used for API seeding. In fresh-account mode /// this is the test's own account; in legacy mode it's `apiCredentials`. private(set) var session: TestSession! private(set) var cleaner: TestDataCleaner! // MARK: - Lifecycle override class func setUp() { super.setUp() guard TestAccountAPIClient.isBackendReachable() else { return } // Ensure both known test accounts exist (covers all subclass credential overrides). // Kratos uses the EMAIL as the login identifier, so log in by email. // NOTE: the admin@honeydue.com / Test1234 created here is the Kratos APP identity // (system (a) in the `apiCredentials` doc above) — NOT the admin-panel SQL // super-admin (admin@honeydue.com / password123) that SuiteZZ uses for the data // wipe. Same email, separate systems; keep Test1234 here. if TestAccountAPIClient.login(username: "testuser@honeydue.com", password: "TestPass123!") == nil { _ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!") } if TestAccountAPIClient.login(username: "admin@honeydue.com", password: "Test1234") == nil { _ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "Test1234") } } /// When `true`, every test in the suite forces a logout → login cycle /// in `setUp`, guaranteeing a freshly-issued auth token on each run. /// /// Default is `false`: tests reuse the existing logged-in session /// from the previous test in the same suite — much faster (one login /// per suite, not one per test) and resilient to suites where the /// current screen has no logout affordance (`UITestHelpers.ensureLoggedOut` /// times out → the test fails before its body runs). /// /// Override to `true` in suites that have observed transient /// `Invalid token` 401s on POST/PATCH while reads continue to work. /// The recipe was added after a 2026-05 incident where the API /// container was rebuilt mid-suite and in-memory tokens went stale. /// In normal CI runs against a stable API + freshly-erased simulator, /// session reuse is the correct default. var forceFreshLoginPerTest: Bool { false } override func setUpWithError() throws { guard TestAccountAPIClient.isBackendReachable() else { throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)") } if usesFreshAccount { // Per-test isolation WITHOUT a UI login: create the account and seed // its UI-gated data via API BEFORE the app launches, then boot the // app already authenticated via the injected session token (see // `additionalLaunchArguments`). relaunchBetweenTests gives every test // a fresh launch, so each boots as its own account; the account is // deleted in teardown (cascading all its data). let acct = TestAccount.create(domain: accountDomain) account = acct session = acct.session cleaner = TestDataCleaner(token: acct.token) seedAccountPreconditions(acct) try super.setUpWithError() // launches with --ui-test-session-token waitForMainApp() return } try super.setUpWithError() // Legacy path: log in as a SPECIFIC seeded account (testCredentials), // optionally opening a separate API session (apiCredentials). let tabBar = app.tabBars.firstMatch let alreadyLoggedIn = tabBar.waitForExistence(timeout: defaultTimeout) if forceFreshLoginPerTest { UITestHelpers.ensureLoggedOut(app: app) loginToMainApp() } else if !alreadyLoggedIn { UITestHelpers.ensureLoggedOut(app: app) loginToMainApp() } if needsAPISession { // Kratos uses the EMAIL as the login identifier. Subclasses still // declare seeded `apiCredentials` by short username (e.g. "admin"), // so normalize bare usernames to their "@honeydue.com" email. let identifier = apiCredentials.username.contains("@") ? apiCredentials.username : "\(apiCredentials.username)@honeydue.com" guard let apiSession = TestAccountManager.loginSeededAccount( username: identifier, password: apiCredentials.password ) else { XCTFail("Could not login API account '\(apiCredentials.username)'") return } session = apiSession cleaner = TestDataCleaner(token: apiSession.token) } } override func tearDownWithError() throws { // Deleting the per-test account cascades all of its data and clears the // Kratos identity in one call. In legacy mode there's no account, so // fall back to tracked-resource cleanup. if let account { account.delete() } else { cleaner?.cleanAll() } try super.tearDownWithError() } // MARK: - Login func loginToMainApp() { let creds = testCredentials UITestHelpers.ensureOnLoginScreen(app: app) let login = LoginScreenObject(app: app) login.waitForLoad(timeout: loginTimeout) // Kratos uses the EMAIL as the login identifier. Subclasses still declare // testCredentials by short username (e.g. "admin"/"testuser"), so normalize // a bare username to "@honeydue.com" for the app's login form. let identifier = creds.username.contains("@") ? creds.username : "\(creds.username)@honeydue.com" login.enterUsername(identifier) login.enterPassword(creds.password) let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton] loginButton.waitForExistenceOrFail(timeout: defaultTimeout) loginButton.tap() waitForMainApp() } func waitForMainApp() { let tabBar = app.tabBars.firstMatch let verification = VerificationScreen(app: app) let deadline = Date().addingTimeInterval(loginTimeout) while Date() < deadline { if tabBar.exists { break } if verification.codeField.exists { verification.enterCode(TestAccountAPIClient.debugVerificationCode) verification.submitCode() _ = tabBar.waitForExistence(timeout: loginTimeout) break } RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } if !tabBar.exists { XCTFail("Expected tab bar after login with '\(testCredentials.username)'. " + "Root state: " + Self.diagnoseRootState(app)) } } /// Diagnostic: report which RootView branch the app is parked on when /// the tab bar fails to appear after login. Helps distinguish a failed login /// (parked on ui.root.login) from a stuck verify-email gate. static func diagnoseRootState(_ app: XCUIApplication) -> String { let login = app.otherElements["ui.root.login"].exists let onboarding = app.otherElements["ui.root.onboarding"].exists let mainTabs = app.otherElements["ui.root.mainTabs"].exists let verifyCode = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField].exists || app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField].exists let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField].exists return "login=\(login) onboarding=\(onboarding) mainTabs=\(mainTabs) " + "verifyCodeField=\(verifyCode) usernameField=\(usernameField)" } // MARK: - Tab Navigation func navigateToTab(_ label: String) { let tabBar = app.tabBars.firstMatch tabBar.waitForExistenceOrFail(timeout: navigationTimeout) let tab = tabBar.buttons.containing( NSPredicate(format: "label CONTAINS[c] %@", label) ).firstMatch tab.waitForExistenceOrFail(timeout: navigationTimeout) tab.tap() // Verify navigation happened — wait for isSelected (best effort, won't fail) // sidebarAdaptable tabs sometimes need a moment let selected = NSPredicate(format: "isSelected == true") let exp = XCTNSPredicateExpectation(predicate: selected, object: tab) let result = XCTWaiter().wait(for: [exp], timeout: navigationTimeout) // If first tap didn't register, tap again if result != .completed { tab.tap() _ = XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: selected, object: tab)], timeout: navigationTimeout) } } func navigateToResidences() { navigateToTab("Residences") } func navigateToTasks() { navigateToTab("Tasks") } func navigateToContractors() { navigateToTab("Contractors") } func navigateToDocuments() { navigateToTab("Doc") } func navigateToProfile() { navigateToTab("Profile") } // MARK: - Pull to Refresh func pullToRefresh() { let scrollable = app.collectionViews.firstMatch.exists ? app.collectionViews.firstMatch : app.scrollViews.firstMatch guard scrollable.waitForExistence(timeout: defaultTimeout) else { return } let start = scrollable.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)) let end = scrollable.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) start.press(forDuration: 0.3, thenDragTo: end) _ = app.activityIndicators.firstMatch.waitForNonExistence(timeout: navigationTimeout) } func pullToRefreshUntilVisible(_ element: XCUIElement, maxRetries: Int = 3) { for _ in 0..