Files
honeyDueKMP/iosApp/HoneyDueUITests/Framework/BaseUITestCase.swift
Trey T 4df8707b92 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>
2026-03-23 15:05:37 -05:00

294 lines
10 KiB
Swift

import XCTest
class BaseUITestCase: XCTestCase {
let app = XCUIApplication()
/// 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
/// (skips onboarding). Default is `false` so tests that navigate from
/// 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
XCUIDevice.shared.orientation = .portrait
// Auto-dismiss any system alerts (notifications, tracking, etc.)
addUIInterruptionMonitor(withDescription: "System Alert") { alert in
let buttons = ["Allow", "OK", "Don't Allow", "Not Now", "Dismiss", "Allow While Using App"]
for label in buttons {
let button = alert.buttons[label]
if button.exists {
button.tap()
return true
}
}
return false
}
var launchArguments = [
"--ui-testing",
"--disable-animations"
]
if completeOnboarding {
launchArguments.append("--complete-onboarding")
}
if includeResetStateLaunchArgument {
launchArguments.append("--reset-state")
}
launchArguments.append(contentsOf: additionalLaunchArguments)
app.launchArguments = launchArguments
// 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 {
if let run = testRun, !run.hasSucceeded {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Failure-\(name)"
attachment.lifetime = .keepAlways
add(attachment)
}
}
}
extension XCUIElement {
@discardableResult
func waitForExistenceOrFail(
timeout: TimeInterval,
message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> XCUIElement {
if !waitForExistence(timeout: timeout) {
XCTFail(message ?? "Expected element to exist: \(self)", file: file, line: line)
}
return self
}
@discardableResult
func waitUntilHittable(
timeout: TimeInterval,
message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> XCUIElement {
let predicate = NSPredicate(format: "exists == true AND hittable == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
if result != .completed {
XCTFail(message ?? "Expected element to become hittable: \(self)", file: file, line: line)
}
return self
}
@discardableResult
func waitForNonExistence(
timeout: TimeInterval,
message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
if result != .completed {
XCTFail(message ?? "Expected element to disappear: \(self)", file: file, line: line)
return false
}
return true
}
func scrollIntoView(
in scrollView: XCUIElement,
maxSwipes: Int = 8,
file: StaticString = #filePath,
line: UInt = #line
) {
if isHittable { return }
for _ in 0..<maxSwipes {
scrollView.swipeUp()
if isHittable { return }
}
for _ in 0..<maxSwipes {
scrollView.swipeDown()
if isHittable { return }
}
XCTFail("Failed to scroll element into view: \(self)", file: file, line: line)
}
func forceTap(file: StaticString = #filePath, line: UInt = #line) {
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 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
}
// 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)
}
}
}