Stabilize UI test suite — 39% → 98%+ pass rate

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.
This commit is contained in:
Trey T
2026-04-15 08:38:31 -05:00
parent 9ececfa48a
commit a4d66c6ed1
17 changed files with 388 additions and 59 deletions

View File

@@ -12,7 +12,7 @@ class AuthenticatedUITestCase: BaseUITestCase {
var needsAPISession: Bool { false }
var apiCredentials: (username: String, password: String) {
("admin", "test1234")
("admin", "Test1234")
}
// MARK: - API Session
@@ -29,8 +29,8 @@ class AuthenticatedUITestCase: BaseUITestCase {
if TestAccountAPIClient.login(username: "testuser", password: "TestPass123!") == nil {
_ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!")
}
if TestAccountAPIClient.login(username: "admin", password: "test1234") == nil {
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "test1234")
if TestAccountAPIClient.login(username: "admin", password: "Test1234") == nil {
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "Test1234")
}
}
@@ -213,13 +213,49 @@ class AuthenticatedUITestCase: BaseUITestCase {
/// Dismiss keyboard using the Return key or toolbar Done button.
func dismissKeyboard() {
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: defaultTimeout)
KeyboardDismisser.dismiss(app: app, timeout: defaultTimeout)
}
}
/// Robust keyboard dismissal. Numeric keyboards (postal, year, cost) often lack
/// Return/Done keys, so we fall back through swipe-down and tap-above strategies.
enum KeyboardDismisser {
static func dismiss(app: XCUIApplication, timeout: TimeInterval = 5) {
let keyboard = app.keyboards.firstMatch
guard keyboard.exists else { return }
// 1. Prefer the keyboard-toolbar "Done" button (SwiftUI ToolbarItemGroup
// on .keyboard placement). Tapping it sets focusedField = nil, which
// reliably commits TextField bindings before the keyboard dismisses.
// We look outside app.keyboards.buttons because the toolbar is
// rendered on the keyboard layer, not inside it.
if keyboard.exists {
let toolbarDone = app.toolbars.buttons["Done"]
if toolbarDone.exists && toolbarDone.isHittable {
toolbarDone.tap()
if keyboard.waitForNonExistence(timeout: 1.0) { return }
}
}
// 2. Tap above the keyboard. This dismisses via focus-loss on the
// underlying UITextField, which propagates the typed text to the
// SwiftUI binding. Works for numeric keyboards too.
if keyboard.exists {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
if keyboard.waitForNonExistence(timeout: 1.0) { return }
}
// 3. Last resort: keyboard Return/Done key. Avoid this first on
// SwiftUI text fields the Return keystroke can dismiss the keyboard
// before the binding catches up with the final typed characters.
for keyName in ["Return", "return", "Done", "done"] {
let button = app.keyboards.buttons[keyName]
if button.exists && button.isHittable {
button.tap()
if keyboard.waitForNonExistence(timeout: 1.0) { return }
}
}
_ = keyboard.waitForNonExistence(timeout: timeout)
}
}

View File

@@ -268,13 +268,12 @@ extension XCUIElement {
return
}
// Dismiss any open keyboard first so this field isn't blocked
// 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 {
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)
KeyboardDismisser.dismiss(app: app)
}
// Wait for the element to be hittable (form may need to adjust after keyboard dismiss)

View File

@@ -186,6 +186,8 @@ struct ResidenceFormScreen {
}
func save() {
KeyboardDismisser.dismiss(app: app)
if !saveButton.exists || !saveButton.isHittable { app.swipeUp() }
saveButton.waitForExistenceOrFail(timeout: 10)
saveButton.forceTap()
_ = saveButton.waitForNonExistence(timeout: 15)

View File

@@ -68,7 +68,7 @@ enum TestAccountManager {
/// Login with a pre-seeded account that already exists in the database.
static func loginSeededAccount(
username: String = "admin",
password: String = "test1234",
password: String = "Test1234",
file: StaticString = #filePath,
line: UInt = #line
) -> TestSession? {