Files
honeyDueKMP/iosApp/HoneyDueUITests/Suite6_ComprehensiveTaskTests.swift
Trey T a4d66c6ed1 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.
2026-04-15 08:38:31 -05:00

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")
}
}