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