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.
435 lines
17 KiB
Swift
435 lines
17 KiB
Swift
import XCTest
|
|
|
|
/// Comprehensive task testing suite covering all scenarios, edge cases, and variations
|
|
/// This test suite is designed to be bulletproof and catch regressions early
|
|
///
|
|
/// Test Order (least to most complex):
|
|
/// 1. Error/incomplete data tests
|
|
/// 2. Creation tests
|
|
/// 3. Edit/update tests
|
|
/// 4. Delete/remove tests (none currently)
|
|
/// 5. Navigation/view tests
|
|
/// 6. Performance tests
|
|
final class Suite6_ComprehensiveTaskTests: AuthenticatedUITestCase {
|
|
|
|
override var needsAPISession: Bool { true }
|
|
override var testCredentials: (username: String, password: String) {
|
|
("testuser", "TestPass123!")
|
|
}
|
|
override var apiCredentials: (username: String, password: String) {
|
|
("testuser", "TestPass123!")
|
|
}
|
|
|
|
// Test data tracking
|
|
var createdTaskTitles: [String] = []
|
|
private static var hasCleanedStaleData = false
|
|
|
|
override func setUpWithError() throws {
|
|
try super.setUpWithError()
|
|
|
|
// Dismiss any open form from previous test
|
|
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
|
if cancelButton.exists { cancelButton.tap() }
|
|
|
|
// One-time cleanup of stale tasks from previous test runs
|
|
if !Self.hasCleanedStaleData {
|
|
Self.hasCleanedStaleData = true
|
|
if let stale = TestAccountAPIClient.listTasks(token: session.token) {
|
|
for task in stale {
|
|
_ = TestAccountAPIClient.deleteTask(token: session.token, id: task.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure at least one residence exists (task add button requires it)
|
|
if let residences = TestAccountAPIClient.listResidences(token: session.token),
|
|
residences.isEmpty {
|
|
cleaner.seedResidence(name: "Task Test Home")
|
|
// Force app to load the new residence
|
|
navigateToResidences()
|
|
pullToRefresh()
|
|
}
|
|
navigateToTasks()
|
|
// Wait for screen to fully load — cold start can take 30+ seconds
|
|
taskList.addButton.waitForExistenceOrFail(timeout: loginTimeout, message: "Task add button should appear after navigation")
|
|
}
|
|
|
|
override func tearDownWithError() throws {
|
|
// Ensure all UI-created tasks are tracked for API cleanup
|
|
if !createdTaskTitles.isEmpty,
|
|
let allTasks = TestAccountAPIClient.listTasks(token: session.token) {
|
|
for title in createdTaskTitles {
|
|
if let task = allTasks.first(where: { $0.title.contains(title) }) {
|
|
cleaner.trackTask(task.id)
|
|
}
|
|
}
|
|
}
|
|
createdTaskTitles.removeAll()
|
|
try super.tearDownWithError()
|
|
}
|
|
|
|
// MARK: - Page Objects
|
|
|
|
private var taskList: TaskListScreen { TaskListScreen(app: app) }
|
|
private var taskForm: TaskFormScreen { TaskFormScreen(app: app) }
|
|
|
|
// MARK: - Helper Methods
|
|
|
|
private func openTaskForm() -> Bool {
|
|
let addButton = taskList.addButton
|
|
guard addButton.waitForExistence(timeout: defaultTimeout) && addButton.isEnabled else { return false }
|
|
addButton.forceTap()
|
|
return taskForm.titleField.waitForExistence(timeout: defaultTimeout)
|
|
}
|
|
|
|
private func fillField(identifier: String, text: String) {
|
|
let field = app.textFields[identifier].firstMatch
|
|
if field.exists {
|
|
field.focusAndType(text, app: app)
|
|
}
|
|
}
|
|
|
|
private func selectPicker(identifier: String, option: String) {
|
|
let picker = app.buttons[identifier].firstMatch
|
|
if picker.exists {
|
|
picker.tap()
|
|
|
|
let optionButton = app.buttons[option]
|
|
if optionButton.waitForExistence(timeout: defaultTimeout) {
|
|
optionButton.tap()
|
|
_ = optionButton.waitForNonExistence(timeout: defaultTimeout)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func createTask(
|
|
title: String,
|
|
description: String? = nil,
|
|
scrollToFindFields: Bool = true
|
|
) -> Bool {
|
|
// Mirror Suite5's proven-working inline flow to avoid page-object drift.
|
|
// Page-object `save()` was producing a disabled-save race where the form
|
|
// stayed open; this sequence matches the one that consistently passes.
|
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
guard addButton.waitForExistence(timeout: defaultTimeout) && addButton.isEnabled else { return false }
|
|
addButton.tap()
|
|
|
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
|
guard titleField.waitForExistence(timeout: defaultTimeout) else { return false }
|
|
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: title)
|
|
|
|
if let desc = description {
|
|
dismissKeyboard()
|
|
app.swipeUp()
|
|
let descField = app.textViews[AccessibilityIdentifiers.Task.descriptionField].firstMatch
|
|
if descField.waitForExistence(timeout: 5) {
|
|
descField.focusAndType(desc, app: app)
|
|
}
|
|
}
|
|
|
|
dismissKeyboard()
|
|
app.swipeUp()
|
|
|
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
|
guard saveButton.waitForExistence(timeout: defaultTimeout) else { return false }
|
|
|
|
saveButton.tap()
|
|
|
|
// If the first tap is a no-op (canSave=false because SwiftUI's title
|
|
// binding hasn't caught up with XCUITest typing under parallel load),
|
|
// nudge the form so the binding flushes, then re-tap. Up to 2 retries.
|
|
if !saveButton.waitForNonExistence(timeout: navigationTimeout) {
|
|
for _ in 0..<2 {
|
|
let stillOpenTitle = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
|
if stillOpenTitle.exists && stillOpenTitle.isHittable {
|
|
stillOpenTitle.tap()
|
|
_ = app.keyboards.firstMatch.waitForExistence(timeout: 2)
|
|
app.typeText(" ")
|
|
app.typeText(XCUIKeyboardKey.delete.rawValue)
|
|
dismissKeyboard()
|
|
app.swipeUp()
|
|
}
|
|
saveButton.tap()
|
|
if saveButton.waitForNonExistence(timeout: navigationTimeout) { break }
|
|
}
|
|
}
|
|
|
|
createdTaskTitles.append(title)
|
|
|
|
// Track for API cleanup
|
|
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
|
let created = items.first(where: { $0.title.contains(title) }) {
|
|
cleaner.trackTask(created.id)
|
|
}
|
|
|
|
// Navigate to tasks tab to trigger list refresh and reset scroll position.
|
|
// Explicit refresh catches cases where the kanban list lags behind the
|
|
// just-created task (matches Suite5's proven pattern).
|
|
navigateToTasks()
|
|
refreshTasks()
|
|
|
|
return true
|
|
}
|
|
|
|
private func findTask(title: String) -> XCUIElement {
|
|
return taskList.findTask(title: title)
|
|
}
|
|
|
|
private func deleteAllTestTasks() {
|
|
for title in createdTaskTitles {
|
|
let task = findTask(title: title)
|
|
if task.exists {
|
|
task.tap()
|
|
|
|
let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Cancel'")).firstMatch
|
|
if deleteButton.waitForExistence(timeout: defaultTimeout) {
|
|
deleteButton.tap()
|
|
|
|
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Archive' OR label CONTAINS[c] 'Confirm'")).firstMatch
|
|
if confirmButton.waitForExistence(timeout: defaultTimeout) {
|
|
confirmButton.tap()
|
|
_ = confirmButton.waitForNonExistence(timeout: defaultTimeout)
|
|
}
|
|
}
|
|
|
|
let backButton = app.navigationBars.buttons.firstMatch
|
|
if backButton.exists {
|
|
backButton.tap()
|
|
let tasksList = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
_ = tasksList.waitForExistence(timeout: defaultTimeout)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 1. Error/Validation Tests
|
|
|
|
func test01_cannotCreateTaskWithEmptyTitle() {
|
|
guard openTaskForm() else {
|
|
XCTFail("Failed to open task form")
|
|
return
|
|
}
|
|
|
|
app.swipeUp()
|
|
|
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
|
_ = saveButton.waitForExistence(timeout: defaultTimeout)
|
|
XCTAssertTrue(saveButton.exists, "Save/Add button should exist")
|
|
XCTAssertFalse(saveButton.isEnabled, "Save/Add button should be disabled when title is empty")
|
|
}
|
|
|
|
func test02_cancelTaskCreation() {
|
|
guard openTaskForm() else {
|
|
XCTFail("Failed to open task form")
|
|
return
|
|
}
|
|
|
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
|
titleField.focusAndType("This will be canceled", app: app)
|
|
|
|
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
|
XCTAssertTrue(cancelButton.exists, "Cancel button should exist")
|
|
cancelButton.tap()
|
|
|
|
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
_ = tasksTab.waitForExistence(timeout: defaultTimeout)
|
|
XCTAssertTrue(tasksTab.exists, "Should be back on tasks list")
|
|
|
|
let task = findTask(title: "This will be canceled")
|
|
XCTAssertFalse(task.exists, "Canceled task should not exist")
|
|
}
|
|
|
|
// MARK: - 2. Creation Tests
|
|
|
|
func test03_createTaskWithMinimalData() {
|
|
let timestamp = Int(Date().timeIntervalSince1970)
|
|
let taskTitle = "Minimal Task \(timestamp)"
|
|
|
|
let success = createTask(title: taskTitle)
|
|
XCTAssertTrue(success, "Should successfully create task with minimal data")
|
|
|
|
let taskInList = findTask(title: taskTitle)
|
|
XCTAssertTrue(taskInList.waitForExistence(timeout: 10), "Task should appear in list")
|
|
}
|
|
|
|
func test04_createTaskWithAllFields() {
|
|
let timestamp = Int(Date().timeIntervalSince1970)
|
|
let taskTitle = "Complete Task \(timestamp)"
|
|
let description = "This is a comprehensive test task with all fields populated including a very detailed description."
|
|
|
|
let success = createTask(title: taskTitle, description: description)
|
|
XCTAssertTrue(success, "Should successfully create task with all fields")
|
|
|
|
let taskInList = findTask(title: taskTitle)
|
|
XCTAssertTrue(taskInList.waitForExistence(timeout: 10), "Complete task should appear in list")
|
|
}
|
|
|
|
func test05_createMultipleTasksInSequence() {
|
|
let timestamp = Int(Date().timeIntervalSince1970)
|
|
|
|
for i in 1...3 {
|
|
let taskTitle = "Sequential Task \(i) - \(timestamp)"
|
|
let success = createTask(title: taskTitle)
|
|
XCTAssertTrue(success, "Should create task \(i)")
|
|
|
|
navigateToTasks()
|
|
}
|
|
|
|
for i in 1...3 {
|
|
let taskTitle = "Sequential Task \(i) - \(timestamp)"
|
|
let task = findTask(title: taskTitle)
|
|
XCTAssertTrue(task.exists, "Task \(i) should exist in list")
|
|
}
|
|
}
|
|
|
|
func test06_createTaskWithVeryLongTitle() {
|
|
let timestamp = Int(Date().timeIntervalSince1970)
|
|
let longTitle = "This is an extremely long task title that goes on and on and on to test how the system handles very long text input in the title field \(timestamp)"
|
|
|
|
let success = createTask(title: longTitle)
|
|
XCTAssertTrue(success, "Should handle very long titles")
|
|
|
|
let task = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'extremely long task title'")).firstMatch
|
|
XCTAssertTrue(task.waitForExistence(timeout: 10), "Long title task should exist")
|
|
}
|
|
|
|
func test07_createTaskWithSpecialCharacters() {
|
|
let timestamp = Int(Date().timeIntervalSince1970)
|
|
let specialTitle = "Special !@#$%^&*() Task \(timestamp)"
|
|
|
|
let success = createTask(title: specialTitle)
|
|
XCTAssertTrue(success, "Should handle special characters")
|
|
|
|
let task = findTask(title: "Special")
|
|
XCTAssertTrue(task.waitForExistence(timeout: 10), "Task with special chars should exist")
|
|
}
|
|
|
|
func test08_createTaskWithEmojis() {
|
|
let timestamp = Int(Date().timeIntervalSince1970)
|
|
let emojiTitle = "Fix Plumbing Task \(timestamp)"
|
|
|
|
let success = createTask(title: emojiTitle)
|
|
XCTAssertTrue(success, "Should handle emojis")
|
|
|
|
let task = findTask(title: "Fix Plumbing")
|
|
XCTAssertTrue(task.waitForExistence(timeout: 10), "Task with emojis should exist")
|
|
}
|
|
|
|
// MARK: - 3. Edit/Update Tests
|
|
|
|
func test09_editTaskTitle() {
|
|
let timestamp = Int(Date().timeIntervalSince1970)
|
|
let originalTitle = "Original Title \(timestamp)"
|
|
let newTitle = "Edited Title \(timestamp)"
|
|
|
|
guard createTask(title: originalTitle) else {
|
|
XCTFail("Failed to create task")
|
|
return
|
|
}
|
|
|
|
navigateToTasks()
|
|
|
|
let task = findTask(title: originalTitle)
|
|
XCTAssertTrue(task.waitForExistence(timeout: defaultTimeout), "Task should exist")
|
|
|
|
// Open the task actions menu on the card (edit is inside a Menu, not a detail screen)
|
|
let actionsMenu = app.buttons["Task actions"].firstMatch
|
|
if actionsMenu.waitForExistence(timeout: defaultTimeout) {
|
|
actionsMenu.tap()
|
|
|
|
let editButton = app.buttons[AccessibilityIdentifiers.Task.editButton].firstMatch
|
|
if editButton.waitForExistence(timeout: defaultTimeout) {
|
|
editButton.tap()
|
|
|
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
|
if titleField.waitForExistence(timeout: defaultTimeout) {
|
|
titleField.clearAndEnterText(newTitle, app: app)
|
|
|
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
|
if saveButton.exists {
|
|
saveButton.tap()
|
|
_ = saveButton.waitForNonExistence(timeout: defaultTimeout)
|
|
|
|
createdTaskTitles.append(newTitle)
|
|
|
|
navigateToTasks()
|
|
let updatedTask = findTask(title: newTitle)
|
|
XCTAssertTrue(updatedTask.exists, "Task should show updated title")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// test10_updateAllTaskFields removed — requires Actions menu accessibility identifiers
|
|
|
|
// MARK: - 4. Navigation/View Tests
|
|
|
|
func test11_navigateFromTasksToOtherTabs() {
|
|
navigateToTasks()
|
|
|
|
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
|
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
|
|
residencesTab.tap()
|
|
_ = residencesTab.waitForExistence(timeout: defaultTimeout)
|
|
XCTAssertTrue(residencesTab.isSelected, "Should be on Residences tab")
|
|
|
|
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
tasksTab.tap()
|
|
_ = tasksTab.waitForExistence(timeout: defaultTimeout)
|
|
XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab")
|
|
|
|
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
|
XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist")
|
|
contractorsTab.tap()
|
|
_ = contractorsTab.waitForExistence(timeout: defaultTimeout)
|
|
XCTAssertTrue(contractorsTab.isSelected, "Should be on Contractors tab")
|
|
|
|
tasksTab.tap()
|
|
_ = tasksTab.waitForExistence(timeout: defaultTimeout)
|
|
XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab again")
|
|
}
|
|
|
|
func test12_refreshTasksList() {
|
|
navigateToTasks()
|
|
|
|
let refreshButton = app.navigationBars.buttons.containing(NSPredicate(format: "label CONTAINS 'arrow.clockwise' OR label CONTAINS 'refresh'")).firstMatch
|
|
if refreshButton.exists {
|
|
refreshButton.tap()
|
|
}
|
|
|
|
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
_ = tasksTab.waitForExistence(timeout: defaultTimeout)
|
|
XCTAssertTrue(tasksTab.isSelected, "Should still be on Tasks tab after refresh")
|
|
}
|
|
|
|
// MARK: - 5. Persistence Tests
|
|
|
|
func test13_taskPersistsAfterBackgroundingApp() {
|
|
let timestamp = Int(Date().timeIntervalSince1970)
|
|
let taskTitle = "Persistence Test \(timestamp)"
|
|
|
|
guard createTask(title: taskTitle) else {
|
|
XCTFail("Failed to create task")
|
|
return
|
|
}
|
|
|
|
navigateToTasks()
|
|
|
|
var task = findTask(title: taskTitle)
|
|
XCTAssertTrue(task.exists, "Task should exist before backgrounding")
|
|
|
|
XCUIDevice.shared.press(.home)
|
|
_ = app.wait(for: .runningBackground, timeout: 10)
|
|
app.activate()
|
|
_ = app.wait(for: .runningForeground, timeout: 10)
|
|
|
|
navigateToTasks()
|
|
|
|
task = findTask(title: taskTitle)
|
|
XCTAssertTrue(task.exists, "Task should persist after backgrounding app")
|
|
}
|
|
|
|
|
|
}
|