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,9 +3,12 @@ import XCTest
class BaseUITestCase: XCTestCase {
let app = XCUIApplication()
let shortTimeout: TimeInterval = 5
let defaultTimeout: TimeInterval = 15
let longTimeout: TimeInterval = 30
/// Element on current screen if it's not there in 2s, the app is broken
let defaultTimeout: TimeInterval = 2
/// Screen transitions, tab switches
let navigationTimeout: TimeInterval = 5
/// Initial auth flow only (cold start)
let loginTimeout: TimeInterval = 15
var includeResetStateLaunchArgument: Bool { true }
/// Override to `true` in tests that need the standalone login screen
@@ -13,6 +16,19 @@ class BaseUITestCase: XCTestCase {
/// onboarding or test onboarding screens work without extra config.
var completeOnboarding: Bool { false }
var additionalLaunchArguments: [String] { [] }
/// Override to `true` in suites where each test needs a clean app launch
/// (e.g., login/onboarding tests that leave stale field text between tests).
var relaunchBetweenTests: Bool { false }
/// Tracks whether the app has been launched for the current test suite.
/// Reset once per suite via `class setUp()`, so the first test in each
/// suite gets a fresh app launch while subsequent tests reuse the session.
private static var hasLaunchedForCurrentSuite = false
override class func setUp() {
super.setUp()
hasLaunchedForCurrentSuite = false
}
override func setUpWithError() throws {
continueAfterFailure = false
@@ -44,8 +60,20 @@ class BaseUITestCase: XCTestCase {
launchArguments.append(contentsOf: additionalLaunchArguments)
app.launchArguments = launchArguments
app.launch()
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: longTimeout)
// First test in each suite always gets a clean app launch (handles parallel clone reuse).
// Subsequent tests reuse the running app unless relaunchBetweenTests is true.
let needsLaunch = !Self.hasLaunchedForCurrentSuite
|| relaunchBetweenTests
|| app.state != .runningForeground
if needsLaunch {
if app.state == .runningForeground {
app.terminate()
}
app.launch()
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: loginTimeout)
Self.hasLaunchedForCurrentSuite = true
}
}
override func tearDownWithError() throws {
@@ -131,14 +159,135 @@ extension XCUIElement {
}
func forceTap(file: StaticString = #filePath, line: UInt = #line) {
if isHittable {
guard exists else {
XCTFail("Element does not exist for tap: \(self)", file: file, line: line)
return
}
tap()
}
/// Robustly acquires keyboard focus and types text into a field.
///
/// Taps the field and verifies focus before typing. For SecureTextFields
/// where `hasKeyboardFocus` is unreliable, types directly after tapping.
///
/// Strategy (in order):
/// 1. Tap element directly (if hittable) or via coordinate
/// 2. Retry with offset coordinate tap
/// 3. Dismiss keyboard, scroll field into view, then retry
/// 4. Final fallback: forceTap + app.typeText
/// Tap a text field and type text. No retries. No coordinate taps. Fail fast.
func focusAndType(
_ text: String,
app: XCUIApplication,
file: StaticString = #filePath,
line: UInt = #line
) {
guard exists else {
XCTFail("Element does not exist: \(self)", file: file, line: line)
return
}
// SecureTextFields may trigger iOS strong password suggestion dialog
// which blocks the regular keyboard. Handle them with a dedicated path.
if elementType == .secureTextField {
tap()
// Dismiss "Choose My Own Password" or "Not Now" if iOS suggests a strong password
let chooseOwn = app.buttons["Choose My Own Password"]
if chooseOwn.waitForExistence(timeout: 1) {
chooseOwn.tap()
} else {
let notNow = app.buttons["Not Now"]
if notNow.exists && notNow.isHittable { notNow.tap() }
}
if app.keyboards.firstMatch.waitForExistence(timeout: 2) {
typeText(text)
} else {
app.typeText(text)
}
return
}
if exists {
coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
// If keyboard is already open (from previous field), dismiss it
// by tapping the navigation bar (a neutral area that won't trigger onSubmit)
if app.keyboards.firstMatch.exists {
let navBar = app.navigationBars.firstMatch
if navBar.exists {
navBar.tap()
}
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
}
// Tap the element XCUIElement.tap() uses accessibility, not coordinates
tap()
// Wait for keyboard proof that this field got focus
guard app.keyboards.firstMatch.waitForExistence(timeout: 2) else {
XCTFail("Keyboard did not appear after tapping: \(self)", file: file, line: line)
return
}
XCTFail("Expected element to exist before forceTap: \(self)", file: file, line: line)
// typeText on element if it fails (email keyboard type bug), use app.typeText
// Since we dismissed the keyboard before tapping, app.typeText targets the correct field
if hasKeyboardFocus {
typeText(text)
} else {
app.typeText(text)
}
}
/// Selects all text in a text field and types replacement text.
///
/// Uses long-press to invoke the editing menu, taps "Select All", then
/// types `newText` which overwrites the selection. This is far more
/// reliable than pressing the delete key repeatedly.
func clearAndEnterText(
_ newText: String,
app: XCUIApplication,
file: StaticString = #filePath,
line: UInt = #line
) {
guard exists else {
XCTFail("Element does not exist for clearAndEnterText: \(self)", file: file, line: line)
return
}
// Dismiss any open keyboard first so this field isn't blocked
if app.keyboards.firstMatch.exists {
let returnKey = app.keyboards.buttons["return"]
let doneKey = app.keyboards.buttons["Done"]
if returnKey.exists { returnKey.tap() }
else if doneKey.exists { doneKey.tap() }
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
}
// Wait for the element to be hittable (form may need to adjust after keyboard dismiss)
let hittablePred = NSPredicate(format: "isHittable == true")
let hittableExp = XCTNSPredicateExpectation(predicate: hittablePred, object: self)
_ = XCTWaiter().wait(for: [hittableExp], timeout: 5)
// Tap to focus
tap()
guard app.keyboards.firstMatch.waitForExistence(timeout: 2) else {
XCTFail("Keyboard did not appear for clearAndEnterText: \(self)", file: file, line: line)
return
}
// Select all text: long-press Select All, or triple-tap
press(forDuration: 1.0)
let selectAll = app.menuItems["Select All"]
if selectAll.waitForExistence(timeout: 2) {
selectAll.tap()
} else {
tap(withNumberOfTaps: 3, numberOfTouches: 1)
}
// Type replacement (replaces selection)
if hasKeyboardFocus {
typeText(newText)
} else {
app.typeText(newText)
}
}
}