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.
471 lines
15 KiB
Swift
471 lines
15 KiB
Swift
import XCTest
|
|
|
|
// MARK: - Task Screens
|
|
|
|
/// Page object for the task list screen (kanban or list view).
|
|
struct TaskListScreen {
|
|
let app: XCUIApplication
|
|
|
|
var addButton: XCUIElement {
|
|
let byID = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
if byID.exists { return byID }
|
|
// Fallback: nav bar plus/Add button
|
|
let navBarButtons = app.navigationBars.buttons
|
|
for i in 0..<navBarButtons.count {
|
|
let button = navBarButtons.element(boundBy: i)
|
|
if button.label == "plus" || button.label.contains("Add") {
|
|
if button.isEnabled { return button }
|
|
}
|
|
}
|
|
// Fallback: empty state add button
|
|
let emptyStateButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task'")).firstMatch
|
|
if emptyStateButton.exists && emptyStateButton.isEnabled { return emptyStateButton }
|
|
return byID
|
|
}
|
|
|
|
var emptyState: XCUIElement {
|
|
app.otherElements[AccessibilityIdentifiers.Task.emptyStateView]
|
|
}
|
|
|
|
var tasksList: XCUIElement {
|
|
app.otherElements[AccessibilityIdentifiers.Task.tasksList]
|
|
}
|
|
|
|
func waitForLoad(timeout: TimeInterval = 15) {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
var loaded = false
|
|
repeat {
|
|
loaded = addButton.exists
|
|
|| emptyState.exists
|
|
|| tasksList.exists
|
|
|| app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch.exists
|
|
if loaded { break }
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
|
} while Date() < deadline
|
|
XCTAssertTrue(loaded, "Expected task list screen to load")
|
|
}
|
|
|
|
func openCreateTask() {
|
|
addButton.waitForExistenceOrFail(timeout: 10)
|
|
addButton.forceTap()
|
|
}
|
|
|
|
func findTask(title: String) -> XCUIElement {
|
|
let predicate = NSPredicate(format: "label CONTAINS %@", title)
|
|
let match = app.staticTexts.containing(predicate).firstMatch
|
|
|
|
// If found immediately, return
|
|
if match.waitForExistence(timeout: 3) { return match }
|
|
|
|
// Scroll through kanban columns (swipe left up to 6 times)
|
|
let scrollView = app.scrollViews.firstMatch
|
|
guard scrollView.exists else { return match }
|
|
|
|
for _ in 0..<6 {
|
|
scrollView.swipeLeft()
|
|
if match.waitForExistence(timeout: 1) { return match }
|
|
}
|
|
|
|
// Scroll back and try right direction
|
|
for _ in 0..<6 {
|
|
scrollView.swipeRight()
|
|
if match.waitForExistence(timeout: 1) { return match }
|
|
}
|
|
|
|
return match
|
|
}
|
|
}
|
|
|
|
/// Page object for the task create/edit form.
|
|
struct TaskFormScreen {
|
|
let app: XCUIApplication
|
|
|
|
var titleField: XCUIElement {
|
|
app.textFields[AccessibilityIdentifiers.Task.titleField]
|
|
}
|
|
|
|
var descriptionField: XCUIElement {
|
|
app.textViews[AccessibilityIdentifiers.Task.descriptionField]
|
|
}
|
|
|
|
var saveButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Task.saveButton]
|
|
}
|
|
|
|
var cancelButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Task.formCancelButton]
|
|
}
|
|
|
|
func waitForLoad(timeout: TimeInterval = 15) {
|
|
XCTAssertTrue(titleField.waitForExistence(timeout: timeout), "Expected task form to load")
|
|
}
|
|
|
|
func enterTitle(_ text: String) {
|
|
titleField.waitForExistenceOrFail(timeout: 10)
|
|
titleField.focusAndType(text, app: app)
|
|
}
|
|
|
|
func enterDescription(_ text: String) {
|
|
app.swipeUp()
|
|
if descriptionField.waitForExistence(timeout: 5) {
|
|
descriptionField.focusAndType(text, app: app)
|
|
}
|
|
}
|
|
|
|
func save() {
|
|
KeyboardDismisser.dismiss(app: app)
|
|
// Scroll the form so any focused-field state commits before the
|
|
// submit action reads it. Without this the title binding can lag.
|
|
app.swipeUp()
|
|
saveButton.waitForExistenceOrFail(timeout: 10)
|
|
saveButton.forceTap()
|
|
_ = saveButton.waitForNonExistence(timeout: 15)
|
|
}
|
|
|
|
func cancel() {
|
|
cancelButton.waitForExistenceOrFail(timeout: 10)
|
|
cancelButton.forceTap()
|
|
}
|
|
}
|
|
|
|
// MARK: - Contractor Screens
|
|
|
|
/// Page object for the contractor list screen.
|
|
struct ContractorListScreen {
|
|
let app: XCUIApplication
|
|
|
|
var addButton: XCUIElement {
|
|
let byID = app.buttons[AccessibilityIdentifiers.Contractor.addButton]
|
|
if byID.exists { return byID }
|
|
let navBarButtons = app.navigationBars.buttons
|
|
for i in 0..<navBarButtons.count {
|
|
let button = navBarButtons.element(boundBy: i)
|
|
if button.label == "plus" || button.label.contains("Add") {
|
|
if button.isEnabled { return button }
|
|
}
|
|
}
|
|
return app.buttons.containing(NSPredicate(format: "label CONTAINS 'plus'")).firstMatch
|
|
}
|
|
|
|
var emptyState: XCUIElement {
|
|
app.otherElements[AccessibilityIdentifiers.Contractor.emptyStateView]
|
|
}
|
|
|
|
var contractorsList: XCUIElement {
|
|
app.otherElements[AccessibilityIdentifiers.Contractor.contractorsList]
|
|
}
|
|
|
|
func waitForLoad(timeout: TimeInterval = 15) {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
var loaded = false
|
|
repeat {
|
|
loaded = addButton.exists
|
|
|| emptyState.exists
|
|
|| contractorsList.exists
|
|
|| app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch.exists
|
|
if loaded { break }
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
|
} while Date() < deadline
|
|
XCTAssertTrue(loaded, "Expected contractor list screen to load")
|
|
}
|
|
|
|
func openCreateContractor() {
|
|
addButton.waitForExistenceOrFail(timeout: 10)
|
|
addButton.forceTap()
|
|
}
|
|
|
|
func findContractor(name: String) -> XCUIElement {
|
|
app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
|
|
}
|
|
}
|
|
|
|
/// Page object for the contractor create/edit form.
|
|
struct ContractorFormScreen {
|
|
let app: XCUIApplication
|
|
|
|
var nameField: XCUIElement {
|
|
app.textFields[AccessibilityIdentifiers.Contractor.nameField]
|
|
}
|
|
|
|
var phoneField: XCUIElement {
|
|
app.textFields[AccessibilityIdentifiers.Contractor.phoneField]
|
|
}
|
|
|
|
var emailField: XCUIElement {
|
|
app.textFields[AccessibilityIdentifiers.Contractor.emailField]
|
|
}
|
|
|
|
var companyField: XCUIElement {
|
|
app.textFields[AccessibilityIdentifiers.Contractor.companyField]
|
|
}
|
|
|
|
var saveButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
|
|
}
|
|
|
|
var cancelButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Contractor.formCancelButton]
|
|
}
|
|
|
|
func waitForLoad(timeout: TimeInterval = 15) {
|
|
XCTAssertTrue(nameField.waitForExistence(timeout: timeout), "Expected contractor form to load")
|
|
}
|
|
|
|
func enterName(_ text: String) {
|
|
nameField.waitForExistenceOrFail(timeout: 10)
|
|
nameField.focusAndType(text, app: app)
|
|
}
|
|
|
|
func enterPhone(_ text: String) {
|
|
if phoneField.waitForExistence(timeout: 5) {
|
|
phoneField.focusAndType(text, app: app)
|
|
}
|
|
}
|
|
|
|
func enterEmail(_ text: String) {
|
|
if emailField.waitForExistence(timeout: 5) {
|
|
emailField.focusAndType(text, app: app)
|
|
}
|
|
}
|
|
|
|
func enterCompany(_ text: String) {
|
|
if companyField.waitForExistence(timeout: 5) {
|
|
companyField.focusAndType(text, app: app)
|
|
}
|
|
}
|
|
|
|
func save() {
|
|
KeyboardDismisser.dismiss(app: app)
|
|
if !saveButton.exists || !saveButton.isHittable { app.swipeUp() }
|
|
saveButton.waitForExistenceOrFail(timeout: 10)
|
|
saveButton.forceTap()
|
|
_ = saveButton.waitForNonExistence(timeout: 15)
|
|
}
|
|
|
|
func cancel() {
|
|
cancelButton.waitForExistenceOrFail(timeout: 10)
|
|
cancelButton.forceTap()
|
|
}
|
|
}
|
|
|
|
/// Page object for the contractor detail screen.
|
|
struct ContractorDetailScreen {
|
|
let app: XCUIApplication
|
|
|
|
var menuButton: XCUIElement {
|
|
let byID = app.buttons[AccessibilityIdentifiers.Contractor.menuButton]
|
|
if byID.exists { return byID }
|
|
return app.images["ellipsis.circle"].firstMatch
|
|
}
|
|
|
|
var editButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Contractor.editButton]
|
|
}
|
|
|
|
var deleteButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Contractor.deleteButton]
|
|
}
|
|
|
|
func waitForLoad(timeout: TimeInterval = 15) {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
var loaded = false
|
|
repeat {
|
|
loaded = menuButton.exists
|
|
|| app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Phone' OR label CONTAINS[c] 'Email'")).firstMatch.exists
|
|
if loaded { break }
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
|
} while Date() < deadline
|
|
XCTAssertTrue(loaded, "Expected contractor detail screen to load")
|
|
}
|
|
|
|
func openMenu() {
|
|
menuButton.waitForExistenceOrFail(timeout: 10)
|
|
menuButton.forceTap()
|
|
}
|
|
|
|
func tapEdit() {
|
|
openMenu()
|
|
editButton.waitForExistenceOrFail(timeout: 10)
|
|
editButton.forceTap()
|
|
}
|
|
|
|
func tapDelete() {
|
|
openMenu()
|
|
deleteButton.waitForExistenceOrFail(timeout: 10)
|
|
deleteButton.forceTap()
|
|
}
|
|
}
|
|
|
|
// MARK: - Document Screens
|
|
|
|
/// Page object for the document list screen.
|
|
struct DocumentListScreen {
|
|
let app: XCUIApplication
|
|
|
|
var addButton: XCUIElement {
|
|
let byID = app.buttons[AccessibilityIdentifiers.Document.addButton]
|
|
if byID.exists { return byID }
|
|
let navBarButtons = app.navigationBars.buttons
|
|
for i in 0..<navBarButtons.count {
|
|
let button = navBarButtons.element(boundBy: i)
|
|
if button.label == "plus" || button.label.contains("Add") {
|
|
if button.isEnabled { return button }
|
|
}
|
|
}
|
|
return app.buttons.containing(NSPredicate(format: "label CONTAINS 'plus'")).firstMatch
|
|
}
|
|
|
|
var emptyState: XCUIElement {
|
|
app.otherElements[AccessibilityIdentifiers.Document.emptyStateView]
|
|
}
|
|
|
|
var documentsList: XCUIElement {
|
|
app.otherElements[AccessibilityIdentifiers.Document.documentsList]
|
|
}
|
|
|
|
func waitForLoad(timeout: TimeInterval = 15) {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
var loaded = false
|
|
repeat {
|
|
loaded = addButton.exists
|
|
|| emptyState.exists
|
|
|| documentsList.exists
|
|
|| app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Documents' OR label CONTAINS[c] 'Warranties'")).firstMatch.exists
|
|
if loaded { break }
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
|
} while Date() < deadline
|
|
XCTAssertTrue(loaded, "Expected document list screen to load")
|
|
}
|
|
|
|
func openCreateDocument() {
|
|
addButton.waitForExistenceOrFail(timeout: 10)
|
|
addButton.forceTap()
|
|
}
|
|
|
|
func findDocument(title: String) -> XCUIElement {
|
|
app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", title)).firstMatch
|
|
}
|
|
}
|
|
|
|
/// Page object for the document create/edit form.
|
|
struct DocumentFormScreen {
|
|
let app: XCUIApplication
|
|
|
|
var titleField: XCUIElement {
|
|
app.textFields[AccessibilityIdentifiers.Document.titleField]
|
|
}
|
|
|
|
var residencePicker: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Document.residencePicker]
|
|
}
|
|
|
|
var saveButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Document.saveButton]
|
|
}
|
|
|
|
var cancelButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Document.formCancelButton]
|
|
}
|
|
|
|
func waitForLoad(timeout: TimeInterval = 15) {
|
|
XCTAssertTrue(titleField.waitForExistence(timeout: timeout), "Expected document form to load")
|
|
}
|
|
|
|
func enterTitle(_ text: String) {
|
|
titleField.waitForExistenceOrFail(timeout: 10)
|
|
titleField.focusAndType(text, app: app)
|
|
}
|
|
|
|
/// Selects a residence by name from the picker. Returns true if selection succeeded.
|
|
@discardableResult
|
|
func selectResidence(name: String) -> Bool {
|
|
guard residencePicker.waitForExistence(timeout: 5) else { return false }
|
|
residencePicker.tap()
|
|
|
|
let menuItem = app.menuItems.firstMatch
|
|
if menuItem.waitForExistence(timeout: 5) {
|
|
// Look for matching item first
|
|
let matchingItem = app.menuItems.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
|
|
if matchingItem.exists {
|
|
matchingItem.tap()
|
|
return true
|
|
}
|
|
// Fallback: tap last available item
|
|
let allItems = app.menuItems.allElementsBoundByIndex
|
|
if let last = allItems.last {
|
|
last.tap()
|
|
return true
|
|
}
|
|
}
|
|
// Dismiss picker if nothing found
|
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).tap()
|
|
return false
|
|
}
|
|
|
|
func save() {
|
|
KeyboardDismisser.dismiss(app: app)
|
|
// Unconditional swipe-up matches the task form fix — forces SwiftUI
|
|
// state to commit before the submit button reads it.
|
|
app.swipeUp()
|
|
saveButton.waitForExistenceOrFail(timeout: 10)
|
|
if saveButton.isHittable {
|
|
saveButton.tap()
|
|
} else {
|
|
saveButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
}
|
|
_ = saveButton.waitForNonExistence(timeout: 15)
|
|
}
|
|
|
|
func cancel() {
|
|
cancelButton.waitForExistenceOrFail(timeout: 10)
|
|
cancelButton.forceTap()
|
|
}
|
|
}
|
|
|
|
// MARK: - Residence Detail Screen
|
|
|
|
/// Page object for the residence detail screen.
|
|
struct ResidenceDetailScreen {
|
|
let app: XCUIApplication
|
|
|
|
var editButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
|
}
|
|
|
|
var deleteButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Residence.deleteButton]
|
|
}
|
|
|
|
var shareButton: XCUIElement {
|
|
app.buttons[AccessibilityIdentifiers.Residence.shareButton]
|
|
}
|
|
|
|
func waitForLoad(timeout: TimeInterval = 15) {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
var loaded = false
|
|
repeat {
|
|
loaded = editButton.exists
|
|
|| app.otherElements[AccessibilityIdentifiers.Residence.detailView].exists
|
|
|| app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch.exists
|
|
if loaded { break }
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
|
} while Date() < deadline
|
|
XCTAssertTrue(loaded, "Expected residence detail screen to load")
|
|
}
|
|
|
|
func tapEdit() {
|
|
editButton.waitForExistenceOrFail(timeout: 10)
|
|
editButton.forceTap()
|
|
}
|
|
|
|
func tapDelete() {
|
|
deleteButton.waitForExistenceOrFail(timeout: 10)
|
|
deleteButton.forceTap()
|
|
}
|
|
|
|
func tapShare() {
|
|
shareButton.waitForExistenceOrFail(timeout: 10)
|
|
shareButton.forceTap()
|
|
}
|
|
}
|