Files
honeyDueKMP/iosApp/HoneyDueUITests/Framework/AuthenticatedTestCase.swift
treyt 5c360a2796 Rearchitect UI test suite for complete, non-flaky coverage against live API
- Migrate Suite4-10, SmokeTests, NavigationCriticalPathTests to AuthenticatedTestCase
  with seeded admin account and real backend login
- Add 34 accessibility identifiers across 11 app views (task completion, profile,
  notifications, theme, join residence, manage users, forms)
- Create FeatureCoverageTests (14 tests) covering previously untested features:
  profile edit, theme selection, notification prefs, task completion, manage users,
  join residence, task templates
- Create MultiUserSharingTests (18 API tests) and MultiUserSharingUITests (8 XCUI
  tests) for full cross-user residence sharing lifecycle
- Add cleanup infrastructure: SuiteZZ_CleanupTests auto-wipes test data after runs,
  cleanup_test_data.sh script for manual reset via admin API
- Add share code API methods to TestAccountAPIClient (generateShareCode, joinWithCode,
  getShareCode, listResidenceUsers, removeUser)
- Fix app bugs found by tests:
  - ResidencesListView join callback now uses forceRefresh:true
  - APILayer invalidates task cache when residence count changes
  - AllTasksView auto-reloads tasks when residence list changes
- Fix test quality: keyboard focus waits, Save/Add button label matching,
  Documents tab label (Docs), remove API verification from UI tests
- DataLayerTests and PasswordResetTests now verify through UI, not API calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:32:13 -05:00

306 lines
12 KiB
Swift

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..<maxRetries {
if element.waitForExistence(timeout: 3) { return }
pullToRefresh()
}
// Final wait after last refresh
_ = element.waitForExistence(timeout: 5)
}
}