UI test infrastructure overhaul — 58% to 96% pass rate (231/241)

Major infrastructure changes:
- BaseUITestCase: per-suite app termination via class setUp() prevents
  stale state when parallel clones share simulators
- relaunchBetweenTests override for suites that modify login/onboarding state
- focusAndType: dedicated SecureTextField path handles iOS strong password
  autofill suggestions (Choose My Own Password / Not Now dialogs)
- LoginScreenObject: tapSignUp/tapForgotPassword use scrollIntoView for
  offscreen buttons instead of simple swipeUp
- Removed all coordinate taps from ForgotPasswordScreen, VerifyResetCodeScreen,
  ResetPasswordScreen (Rule 3 compliance)
- Removed all usleep calls from screen objects (Rule 14 compliance)

App fixes exposed by tests:
- ContractorsListView: added onDismiss to sheet for list refresh after save
- AllTasksView: added Task.RefreshButton accessibility identifier
- AccessibilityIdentifiers: added Task.refreshButton
- DocumentsWarrantiesView: onDismiss handler for document list refresh
- Various form views: textContentType, submitLabel, onSubmit for keyboard flow

Test fixes:
- PasswordResetTests: handle auto-login after reset (app skips success screen)
- AuthenticatedUITestCase: refreshTasks() helper for kanban toolbar button
- All pre-login suites use relaunchBetweenTests for test independence
- Deleted dead code: AuthenticatedTestCase, SeededTestData, SeedTests,
  CleanupTests, old Suite0/2/3, Suite1_RegistrationRebuildTests

10 remaining failures: 5 iOS strong password autofill (simulator env),
3 pull-to-refresh gesture on empty lists, 2 feature coverage edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-03-23 15:05:37 -05:00
parent 0ca4a44bac
commit 4df8707b92
67 changed files with 3085 additions and 4853 deletions

View File

@@ -3,18 +3,17 @@ import XCTest
/// XCUITests for multi-user residence sharing.
///
/// Pattern: User A's data is seeded via API before app launch.
/// The app launches logged in as User B (via AuthenticatedTestCase).
/// The app launches logged in as User B (via AuthenticatedUITestCase with UI-driven login).
/// User B joins User A's residence through the UI and verifies shared data.
///
/// ALL assertions check UI elements only. If the UI doesn't show the expected
/// data, that indicates a real app bug and the test should fail.
final class MultiUserSharingUITests: AuthenticatedTestCase {
// Use a fresh account for User B (not the seeded admin)
override var useSeededAccount: Bool { false }
final class MultiUserSharingUITests: AuthenticatedUITestCase {
/// User A's session (API-only, set up before app launch)
private var userASession: TestSession!
/// User B's session (fresh account, logged in via UI)
private var userBSession: TestSession!
/// The shared residence ID
private var sharedResidenceId: Int!
/// The share code User B will enter in the UI
@@ -25,6 +24,15 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
private var userATaskTitle: String!
private var userADocTitle: String!
/// Stored credentials for User B, set before super.setUpWithError() calls loginToMainApp()
private var _userBUsername: String = ""
private var _userBPassword: String = ""
/// Dynamic credentials returns User B's freshly created account
override var testCredentials: (username: String, password: String) {
(_userBUsername, _userBPassword)
}
override func setUpWithError() throws {
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Local backend not reachable")
@@ -37,7 +45,7 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
email: "owner_\(runId)@test.com",
password: "TestPass123!"
) else {
throw XCTSkip("Could not create User A (owner)")
XCTFail("Could not create User A (owner)"); return
}
userASession = a
@@ -47,7 +55,7 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
token: userASession.token,
name: sharedResidenceName
) else {
throw XCTSkip("Could not create residence for User A")
XCTFail("Could not create residence for User A"); return
}
sharedResidenceId = residence.id
@@ -56,7 +64,7 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
token: userASession.token,
residenceId: sharedResidenceId
) else {
throw XCTSkip("Could not generate share code")
XCTFail("Could not generate share code"); return
}
shareCode = code.code
@@ -76,7 +84,17 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
documentType: "warranty"
)
// Now launch the app as User B (AuthenticatedTestCase creates a fresh account)
// Create User B via API (fresh account)
guard let b = TestAccountManager.createVerifiedAccount() else {
XCTFail("Could not create User B (fresh account)"); return
}
userBSession = b
// Set User B's credentials BEFORE super.setUpWithError() calls loginToMainApp()
_userBUsername = b.username
_userBPassword = b.password
// Now launch the app and login as User B via base class
try super.setUpWithError()
}
@@ -92,13 +110,11 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
func test01_joinResidenceWithShareCode() {
navigateToResidences()
sleep(2)
// Tap the join button (person.badge.plus icon in toolbar)
let joinButton = findJoinButton()
XCTAssertTrue(joinButton.waitForExistence(timeout: defaultTimeout), "Join button should exist")
joinButton.tap()
sleep(2)
// Verify JoinResidenceView appeared
let codeField = app.textFields["JoinResidence.ShareCodeField"]
@@ -107,18 +123,16 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
// Type the share code
codeField.tap()
sleep(1)
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText(shareCode)
sleep(1)
// Tap Join
let joinAction = app.buttons["JoinResidence.JoinButton"]
XCTAssertTrue(joinAction.waitForExistence(timeout: shortTimeout), "Join button should exist")
XCTAssertTrue(joinAction.waitForExistence(timeout: defaultTimeout), "Join button should exist")
XCTAssertTrue(joinAction.isEnabled, "Join button should be enabled with 6-char code")
joinAction.tap()
// Wait for join to complete the sheet should dismiss
sleep(5)
// Verify the join screen dismissed (code field should be gone)
let codeFieldGone = codeField.waitForNonExistence(timeout: 10)
@@ -150,7 +164,6 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
// Navigate to Documents tab and verify User A's document title appears
navigateToDocuments()
sleep(3)
let docText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", userADocTitle)
@@ -178,25 +191,24 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
XCTAssertTrue(sharedRes.waitForExistence(timeout: defaultTimeout),
"Shared residence should be visible before navigating to Tasks")
// Wait for cache invalidation to propagate before switching tabs
sleep(3)
// Navigate to Tasks tab
navigateToTasks()
sleep(3)
// Tap the refresh button (arrow.clockwise) to force-reload tasks
let refreshButton = app.navigationBars.buttons.containing(
NSPredicate(format: "label CONTAINS 'arrow.clockwise'")
).firstMatch
for attempt in 0..<5 {
for _ in 0..<5 {
if refreshButton.waitForExistence(timeout: 3) && refreshButton.isEnabled {
refreshButton.tap()
sleep(5)
// Wait for task data to load
_ = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", userATaskTitle)
).firstMatch.waitForExistence(timeout: defaultTimeout)
break
}
// If disabled, wait for residence data to propagate
sleep(2)
_ = refreshButton.waitForExistence(timeout: 3)
}
// Search for User A's task title it may be in any kanban column
@@ -208,7 +220,6 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
for _ in 0..<5 {
if taskText.exists { break }
app.swipeLeft()
sleep(1)
}
XCTAssertTrue(taskText.waitForExistence(timeout: defaultTimeout),
@@ -220,7 +231,6 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
func test04_sharedResidenceShowsInDocumentsTab() {
joinResidenceViaUI()
navigateToDocuments()
sleep(3)
// Look for User A's document
let docText = app.staticTexts.containing(
@@ -243,7 +253,6 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
// Navigate to Documents tab
navigateToDocuments()
sleep(3)
// Verify User A's seeded document appears
let docText = app.staticTexts.containing(
@@ -258,14 +267,12 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
func test06_joinResidenceButtonDisabledWithShortCode() {
navigateToResidences()
sleep(2)
let joinButton = findJoinButton()
guard joinButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Join button should exist"); return
}
joinButton.tap()
sleep(2)
let codeField = app.textFields["JoinResidence.ShareCodeField"]
guard codeField.waitForExistence(timeout: defaultTimeout) else {
@@ -274,9 +281,8 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
// Type only 3 characters
codeField.tap()
sleep(1)
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("ABC")
sleep(1)
let joinAction = app.buttons["JoinResidence.JoinButton"]
XCTAssertTrue(joinAction.exists, "Join button should exist")
@@ -287,21 +293,18 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
NSPredicate(format: "label CONTAINS[c] 'Cancel' OR label CONTAINS[c] 'xmark'")
).firstMatch
if dismissButton.exists { dismissButton.tap() }
sleep(1)
}
// MARK: - Test 07: Invalid Code Shows Error
func test07_joinWithInvalidCodeShowsError() {
navigateToResidences()
sleep(2)
let joinButton = findJoinButton()
guard joinButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Join button should exist"); return
}
joinButton.tap()
sleep(2)
let codeField = app.textFields["JoinResidence.ShareCodeField"]
guard codeField.waitForExistence(timeout: defaultTimeout) else {
@@ -310,18 +313,19 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
// Type an invalid 6-char code
codeField.tap()
sleep(1)
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("ZZZZZZ")
sleep(1)
let joinAction = app.buttons["JoinResidence.JoinButton"]
joinAction.tap()
sleep(5)
// Should show an error message (code field should still be visible = still on join screen)
// Wait for API response - either error text appears or we stay on join screen
let errorText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'error' OR label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'not found' OR label CONTAINS[c] 'expired'")
).firstMatch
_ = errorText.waitForExistence(timeout: defaultTimeout)
// Should show an error message (code field should still be visible = still on join screen)
let stillOnJoinScreen = codeField.exists
XCTAssertTrue(errorText.exists || stillOnJoinScreen,
@@ -332,7 +336,6 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
NSPredicate(format: "label CONTAINS[c] 'Cancel' OR label CONTAINS[c] 'xmark'")
).firstMatch
if dismissButton.exists { dismissButton.tap() }
sleep(1)
}
// MARK: - Test 08: Residence Detail Shows After Join
@@ -349,7 +352,6 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
XCTAssertTrue(residenceText.exists,
"Shared residence '\(sharedResidenceName!)' should appear in Residences list")
residenceText.tap()
sleep(3)
// Verify the residence detail view loads and shows the residence name
let detailTitle = app.staticTexts.containing(
@@ -394,33 +396,31 @@ final class MultiUserSharingUITests: AuthenticatedTestCase {
/// After joining, verifies the join sheet dismissed and returns to the Residences list.
private func joinResidenceViaUI() {
navigateToResidences()
sleep(2)
let joinButton = findJoinButton()
guard joinButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Join button not found"); return
}
joinButton.tap()
sleep(2)
let codeField = app.textFields["JoinResidence.ShareCodeField"]
guard codeField.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Share code field not found"); return
}
codeField.tap()
sleep(1)
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText(shareCode)
sleep(1)
let joinAction = app.buttons["JoinResidence.JoinButton"]
guard joinAction.waitForExistence(timeout: shortTimeout), joinAction.isEnabled else {
guard joinAction.waitForExistence(timeout: defaultTimeout), joinAction.isEnabled else {
XCTFail("Join button not enabled"); return
}
joinAction.tap()
sleep(5)
// After join, the sheet dismisses and list should refresh
// After join, wait for the sheet to dismiss
_ = codeField.waitForNonExistence(timeout: loginTimeout)
// List should refresh
pullToRefresh()
sleep(3)
}
}