Files
honeyDueKMP/iosApp/HoneyDueUITests/Framework/AuthenticatedUITestCase.swift
T
Trey T c52ce4d497 Re-architect iOS XCUITest suite: per-test isolation + domain organization
Migrate the XCUITest suite off the legacy shared-account model (and the
prior Django-style auth assumptions) to a parallel-safe, domain-organized
architecture, validated end-to-end against the live Kratos stack.

Isolation (parallel-safe by construction):
- Core/Fixtures/TestAccount.swift: each test mints its own pre-verified
  Kratos identity (uit_<domain>_<uuid>@test.honeydue.local), logs in, seeds
  under its own token, and deletes the identity in teardown (cascading all
  data + clearing Kratos). No shared testuser; parallel workers no longer race.
- AuthenticatedUITestCase rewritten to that model (member surface preserved);
  adds requiresResidence / seedAccountPreconditions to seed UI-gated data
  BEFORE login (a fresh account is empty at login).

Organization (255 tests preserved, none dropped):
- 21 domain suites under Auth/ Onboarding/ Residence/ Task/ Contractor/
  Document/ Sharing/ Navigation/ Smoke/ CrossCutting/ E2E/, consistent
  <Domain>UITests naming. Removes the Suite1..11 / AAA_ / ZZ_ / Tests/Rebuild
  naming chaos and the overlapping task/residence/auth suites.

Runner + test plans:
- run_ui_tests.sh: Smoke gate -> Seed -> Parallel(8 workers) -> Sweep. The
  parallel phase runs the whole target minus phase-managed suites via
  -skip-testing, so new suites auto-include (no hand-maintained list to drift).
  Drops the 2-worker cap and Suite6 isolation (isolation made them moot).
- HoneyDueUITests.xctestplan skips the 4 phase-managed suites; adds Smoke.xctestplan.

Kratos auth fixes folded in (login/verify/reset endpoints removed under Kratos):
real Mailpit verification codes replace the obsolete fixed "123456"; teardown
deletes Kratos identities; admin-panel login uses the correct seeded password.

Build green; isolation, parallelism, and the precondition/sharing migrations
validated against the live stack (0 leaked accounts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:26:50 -05:00

380 lines
17 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
/// 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_<domain>_<uuid>@...),
/// 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)")
}
try super.setUpWithError()
if usesFreshAccount {
// Per-test isolation: every test logs in as its OWN fresh, pre-verified
// account, seeds under its token, and deletes it in teardown. The app
// may be reused from a previous test (still logged in as that test's
// account), so always log out first.
UITestHelpers.ensureLoggedOut(app: app)
let acct = TestAccount.create(domain: accountDomain)
account = acct
session = acct.session
cleaner = TestDataCleaner(token: acct.token)
// Seed UI-gated baseline data BEFORE login so the app loads it on
// its post-login fetch (a fresh account is otherwise empty).
seedAccountPreconditions(acct)
acct.login(into: app, timeout: loginTimeout)
waitForMainApp()
return
}
// 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 "<username>@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 "<username>@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..<maxRetries {
if element.waitForExistence(timeout: defaultTimeout) { return }
pullToRefresh()
}
}
/// Tap the refresh button on the Tasks/Kanban screen (no pull-to-refresh on kanban).
func refreshTasks() {
let refreshButton = app.buttons[AccessibilityIdentifiers.Task.refreshButton]
if refreshButton.waitForExistence(timeout: defaultTimeout) && refreshButton.isEnabled {
refreshButton.tap()
_ = app.activityIndicators.firstMatch.waitForNonExistence(timeout: navigationTimeout)
}
}
// MARK: - Preconditions (Rule 17: validate assumptions via API before tests run)
/// Ensure at least one residence exists for the current user.
/// Required precondition for: task creation, document creation.
func ensureResidenceExists() {
guard let token = session?.token else { return }
if let residences = TestAccountAPIClient.listResidences(token: token),
!residences.isEmpty { return }
// No residence create one via API
let _ = TestDataSeeder.createResidence(token: token, name: "Precondition Home \(Int(Date().timeIntervalSince1970))")
}
/// Ensure the current user has a specific minimum of residences.
func ensureResidenceCount(minimum: Int) {
guard let token = session?.token else { return }
let existing = TestAccountAPIClient.listResidences(token: token) ?? []
for i in existing.count..<minimum {
let _ = TestDataSeeder.createResidence(token: token, name: "Precondition Home \(i) \(Int(Date().timeIntervalSince1970))")
}
}
// MARK: - Shared Helpers
/// Fill a text field by accessibility identifier. The ONE way to type into fields.
func fillTextField(identifier: String, text: String, file: StaticString = #filePath, line: UInt = #line) {
let field = app.textFields[identifier].firstMatch
field.waitForExistenceOrFail(timeout: defaultTimeout, file: file, line: line)
field.focusAndType(text, app: app, file: file, line: line)
}
/// Dismiss keyboard using the Return key or toolbar Done button.
func dismissKeyboard() {
KeyboardDismisser.dismiss(app: app, timeout: defaultTimeout)
}
}
/// Robust keyboard dismissal. Numeric keyboards (postal, year, cost) often lack
/// Return/Done keys, so we fall back through swipe-down and tap-above strategies.
enum KeyboardDismisser {
static func dismiss(app: XCUIApplication, timeout: TimeInterval = 5) {
let keyboard = app.keyboards.firstMatch
guard keyboard.exists else { return }
// 1. Prefer the keyboard-toolbar "Done" button (SwiftUI ToolbarItemGroup
// on .keyboard placement). Tapping it sets focusedField = nil, which
// reliably commits TextField bindings before the keyboard dismisses.
// We look outside app.keyboards.buttons because the toolbar is
// rendered on the keyboard layer, not inside it.
if keyboard.exists {
let toolbarDone = app.toolbars.buttons["Done"]
if toolbarDone.exists && toolbarDone.isHittable {
toolbarDone.tap()
if keyboard.waitForNonExistence(timeout: 1.0) { return }
}
}
// 2. Tap above the keyboard. This dismisses via focus-loss on the
// underlying UITextField, which propagates the typed text to the
// SwiftUI binding. Works for numeric keyboards too.
if keyboard.exists {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
if keyboard.waitForNonExistence(timeout: 1.0) { return }
}
// 3. Last resort: keyboard Return/Done key. Avoid this first on
// SwiftUI text fields the Return keystroke can dismiss the keyboard
// before the binding catches up with the final typed characters.
for keyName in ["Return", "return", "Done", "done"] {
let button = app.keyboards.buttons[keyName]
if button.exists && button.isHittable {
button.tap()
if keyboard.waitForNonExistence(timeout: 1.0) { return }
}
}
_ = keyboard.waitForNonExistence(timeout: timeout)
}
}