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>
This commit is contained in:
@@ -38,6 +38,14 @@ class AuthenticatedTestCase: BaseUITestCase {
|
||||
/// 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] { [] }
|
||||
|
||||
@@ -71,6 +79,11 @@ class AuthenticatedTestCase: BaseUITestCase {
|
||||
// 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()
|
||||
@@ -85,25 +98,87 @@ class AuthenticatedTestCase: BaseUITestCase {
|
||||
|
||||
// MARK: - UI Login
|
||||
|
||||
/// Navigate from onboarding welcome → login screen → type credentials → wait for main tabs.
|
||||
/// Navigate to login screen → type credentials → wait for main tabs.
|
||||
func loginViaUI() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
// 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)
|
||||
|
||||
// Tap the login button
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
|
||||
// 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 mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
|
||||
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 {
|
||||
@@ -117,24 +192,67 @@ class AuthenticatedTestCase: BaseUITestCase {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
XCTFail("Failed to reach main app after login. Debug tree:\n\(app.debugDescription)")
|
||||
// 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) {
|
||||
let tabButton = app.buttons[tab]
|
||||
if tabButton.waitForExistence(timeout: defaultTimeout) {
|
||||
tabButton.forceTap()
|
||||
} else {
|
||||
// Fallback: search tab bar buttons by label
|
||||
let label = tab.replacingOccurrences(of: "TabBar.", with: "")
|
||||
let byLabel = app.tabBars.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] %@", label)
|
||||
).firstMatch
|
||||
byLabel.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
byLabel.forceTap()
|
||||
// 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() {
|
||||
@@ -156,4 +274,32 @@ class AuthenticatedTestCase: BaseUITestCase {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user