Files
honeyDueKMP/iosApp/HoneyDueUITests/Framework/BaseUITestCase.swift
Trey T 5bb27034aa Fix UI test failures: registration dismiss cascade, onboarding reset, test stability
- Fix registration flow dismiss cascade: chain fullScreenCover → sheet onDismiss
  so auth state is set only after all UIKit presentations are removed, preventing
  RootView from swapping LoginView→MainTabView behind a stale sheet
- Fix onboarding reset: set hasCompletedOnboarding directly instead of calling
  completeOnboarding() which has an auth guard that fails after DataManager.clear()
- Stabilize Suite1 registration tests, Suite6 task tests, Suite7 contractor tests
- Add clean-slate-per-suite via AuthenticatedUITestCase reset state
- Improve test account seeding and screen object reliability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:11:47 -05:00

310 lines
11 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 {
// Dismiss any open keyboard first iOS 26 fails to transfer focus
// from a TextField to a SecureTextField if the keyboard is already up.
if app.keyboards.firstMatch.exists {
let navBar = app.navigationBars.firstMatch
if navBar.exists {
navBar.tap()
}
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
}
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: 0.5) {
chooseOwn.tap()
} else {
let notNow = app.buttons["Not Now"]
if notNow.exists && notNow.isHittable { notNow.tap() }
}
// Wait for keyboard after tapping SecureTextField
if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
// Retry tap first tap may not have acquired focus
tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
}
if app.keyboards.firstMatch.exists {
typeText(text)
} else {
XCTFail("Keyboard did not appear after tapping SecureTextField: \(self)", file: file, line: line)
}
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)
}
}
}