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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user