Fix root causes uncovered across repeated parallel runs: - Admin seed password "test1234" failed backend complexity (needs uppercase). Bumped to "Test1234" across every hard-coded reference (AuthenticatedUITestCase default, TestAccountManager seeded-login default, Tests/*Integration suites, Tests/DataLayer, OnboardingTests). - dismissKeyboard() tapped the Return key first, which races SwiftUI's TextField binding on numeric keyboards (postal, year built) and complex forms. KeyboardDismisser now prefers the keyboard-toolbar Done button, falls back to tap-above-keyboard, then keyboard Return. BaseUITestCase.clearAndEnterText uses the same helper. - Form page-object save() helpers (task / residence / contractor / document) now dismiss the keyboard and scroll the submit button into view before tapping, eliminating Suite4/6/7/8 "save button stayed visible" timeouts. - Suite6 createTask was producing a disabled-save race: under parallel contention the SwiftUI title binding lagged behind XCUITest typing. Rewritten to inline Suite5's proven pattern with a retry that nudges the title binding via a no-op edit when Add is disabled, and an explicit refreshTasks after creation. - Suite8 selectProperty now picks the residence by name (works with menu, list, or wheel picker variants) — avoids bad form-cell taps when the picker hasn't fully rendered. - run_ui_tests.sh uses 2 workers instead of 4 (4-worker contention caused XCUITest typing races across Suite5/7/8) and isolates Suite6 in its own 2-worker phase after the main parallel phase. - Add AAA_SeedTests / SuiteZZ_CleanupTests: the runner's Phase 1 (seed) and Phase 3 (cleanup) depend on these and they were missing from version control.
309 lines
11 KiB
Swift
309 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.
|
|
// KeyboardDismisser tries a toolbar Done + tap-above strategy before
|
|
// falling back to the Return key — this avoids scroll-to-visible
|
|
// errors when the keyboard is mid-transition.
|
|
if app.keyboards.firstMatch.exists {
|
|
KeyboardDismisser.dismiss(app: app)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|