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.
215 lines
9.2 KiB
Swift
215 lines
9.2 KiB
Swift
import XCTest
|
|
|
|
/// Integration tests for task operations against the real local backend.
|
|
///
|
|
/// Test Plan IDs: TASK-010, TASK-012, plus create/edit flows.
|
|
/// Data is seeded via API and cleaned up in tearDown.
|
|
final class TaskIntegrationTests: AuthenticatedUITestCase {
|
|
override var needsAPISession: Bool { true }
|
|
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
|
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
|
|
|
|
// MARK: - Create Task
|
|
|
|
func testTASK_CreateTaskAppearsInList() {
|
|
// Seed a residence via API so task creation has a valid target
|
|
let residence = cleaner.seedResidence()
|
|
|
|
navigateToTasks()
|
|
|
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.emptyStateView]
|
|
let taskList = app.otherElements[AccessibilityIdentifiers.Task.tasksList]
|
|
|
|
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|
|
|| emptyState.waitForExistence(timeout: 3)
|
|
|| taskList.waitForExistence(timeout: 3)
|
|
XCTAssertTrue(loaded, "Tasks screen should load")
|
|
|
|
if addButton.exists && addButton.isHittable {
|
|
addButton.forceTap()
|
|
} else {
|
|
let emptyAddButton = app.buttons.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
|
).firstMatch
|
|
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
emptyAddButton.forceTap()
|
|
}
|
|
|
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
|
|
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
let uniqueTitle = "IntTest Task \(Int(Date().timeIntervalSince1970))"
|
|
titleField.forceTap()
|
|
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
titleField.typeText(uniqueTitle)
|
|
|
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
|
|
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
|
|
saveButton.scrollIntoView(in: scrollContainer)
|
|
saveButton.forceTap()
|
|
|
|
let newTask = app.staticTexts[uniqueTitle]
|
|
XCTAssertTrue(
|
|
newTask.waitForExistence(timeout: loginTimeout),
|
|
"Newly created task should appear"
|
|
)
|
|
}
|
|
|
|
// MARK: - TASK-010: Uncancel Task
|
|
|
|
func testTASK010_UncancelTaskFlow() throws {
|
|
// Seed a cancelled task via API
|
|
let residence = cleaner.seedResidence()
|
|
let cancelledTask = TestDataSeeder.createCancelledTask(token: session.token, residenceId: residence.id)
|
|
cleaner.trackTask(cancelledTask.id)
|
|
|
|
navigateToTasks()
|
|
|
|
// Pull to refresh until the cancelled task is visible
|
|
let taskText = app.staticTexts[cancelledTask.title]
|
|
pullToRefreshUntilVisible(taskText)
|
|
guard taskText.waitForExistence(timeout: defaultTimeout) else {
|
|
throw XCTSkip("Cancelled task not visible in current view")
|
|
}
|
|
taskText.forceTap()
|
|
|
|
// Look for an uncancel or reopen button
|
|
let uncancelButton = app.buttons.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
|
|
).firstMatch
|
|
|
|
if uncancelButton.waitForExistence(timeout: defaultTimeout) {
|
|
uncancelButton.forceTap()
|
|
|
|
let statusText = app.staticTexts.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Cancelled'")
|
|
).firstMatch
|
|
XCTAssertFalse(statusText.exists, "Task should no longer show as cancelled after uncancel")
|
|
}
|
|
}
|
|
|
|
// MARK: - TASK-010 (v2): Uncancel Task — Restores Cancelled Task to Active Lifecycle
|
|
|
|
func test15_uncancelRestorescancelledTask() throws {
|
|
// Seed a residence and a task, then cancel the task via API
|
|
let residence = cleaner.seedResidence(name: "Uncancel Test Residence \(Int(Date().timeIntervalSince1970))")
|
|
let task = cleaner.seedTask(residenceId: residence.id, title: "Uncancel Me \(Int(Date().timeIntervalSince1970))")
|
|
guard TestAccountAPIClient.cancelTask(token: session.token, id: task.id) != nil else {
|
|
throw XCTSkip("Could not cancel task via API — skipping uncancel test")
|
|
}
|
|
|
|
navigateToTasks()
|
|
|
|
// Pull to refresh until the cancelled task is visible
|
|
let taskText = app.staticTexts[task.title]
|
|
pullToRefreshUntilVisible(taskText)
|
|
guard taskText.waitForExistence(timeout: loginTimeout) else {
|
|
throw XCTSkip("Cancelled task '\(task.title)' not visible — may require a Cancelled filter to be active")
|
|
}
|
|
taskText.forceTap()
|
|
|
|
// Look for an uncancel / reopen / restore action
|
|
let uncancelButton = app.buttons.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
|
|
).firstMatch
|
|
|
|
guard uncancelButton.waitForExistence(timeout: defaultTimeout) else {
|
|
throw XCTSkip("No uncancel button found — feature may not yet be implemented in UI")
|
|
}
|
|
uncancelButton.forceTap()
|
|
|
|
// After uncancelling, the task should no longer show a Cancelled status label
|
|
let cancelledLabel = app.staticTexts.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Cancelled'")
|
|
).firstMatch
|
|
XCTAssertFalse(
|
|
cancelledLabel.waitForExistence(timeout: defaultTimeout),
|
|
"Task should no longer display 'Cancelled' status after being restored"
|
|
)
|
|
}
|
|
|
|
// MARK: - TASK-012: Delete Task
|
|
|
|
func testTASK012_DeleteTaskUpdatesViews() {
|
|
// Create a task via UI first (since Kanban board uses cached data)
|
|
let residence = cleaner.seedResidence()
|
|
navigateToTasks()
|
|
|
|
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
|
let emptyAddButton = app.buttons.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
|
).firstMatch
|
|
|
|
let addVisible = addButton.waitForExistence(timeout: defaultTimeout) || emptyAddButton.waitForExistence(timeout: 3)
|
|
XCTAssertTrue(addVisible, "Add task button should be visible")
|
|
|
|
if addButton.exists && addButton.isHittable {
|
|
addButton.forceTap()
|
|
} else {
|
|
emptyAddButton.forceTap()
|
|
}
|
|
|
|
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
|
|
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
let uniqueTitle = "Delete Task \(Int(Date().timeIntervalSince1970))"
|
|
titleField.forceTap()
|
|
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
titleField.typeText(uniqueTitle)
|
|
|
|
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
|
|
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
|
|
if scrollContainer.exists {
|
|
saveButton.scrollIntoView(in: scrollContainer)
|
|
}
|
|
saveButton.forceTap()
|
|
|
|
// Wait for the task to appear in the Kanban board
|
|
let taskText = app.staticTexts[uniqueTitle]
|
|
taskText.waitForExistenceOrFail(timeout: loginTimeout)
|
|
|
|
// Tap the "Actions" menu on the task card to reveal cancel option
|
|
let actionsMenu = app.buttons.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Actions'")
|
|
).firstMatch
|
|
if actionsMenu.waitForExistence(timeout: defaultTimeout) {
|
|
actionsMenu.forceTap()
|
|
} else {
|
|
taskText.forceTap()
|
|
}
|
|
|
|
// Tap cancel (tasks use "Cancel Task" semantics)
|
|
let deleteButton = app.buttons[AccessibilityIdentifiers.Task.deleteButton]
|
|
if !deleteButton.waitForExistence(timeout: defaultTimeout) {
|
|
let cancelTask = app.buttons.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Cancel Task'")
|
|
).firstMatch
|
|
cancelTask.waitForExistenceOrFail(timeout: 5)
|
|
cancelTask.forceTap()
|
|
} else {
|
|
deleteButton.forceTap()
|
|
}
|
|
|
|
// Confirm cancellation
|
|
let confirmDelete = app.alerts.buttons.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes' OR label CONTAINS[c] 'Cancel Task'")
|
|
).firstMatch
|
|
let alertConfirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
|
|
|
if alertConfirmButton.waitForExistence(timeout: defaultTimeout) {
|
|
alertConfirmButton.tap()
|
|
} else if confirmDelete.waitForExistence(timeout: defaultTimeout) {
|
|
confirmDelete.tap()
|
|
}
|
|
|
|
// Refresh the task list (kanban uses toolbar button, not pull-to-refresh)
|
|
refreshTasks()
|
|
|
|
// Verify the task is removed or moved to a different column
|
|
let deletedTask = app.staticTexts[uniqueTitle]
|
|
XCTAssertTrue(
|
|
deletedTask.waitForNonExistence(timeout: loginTimeout),
|
|
"Cancelled task should no longer appear in active views"
|
|
)
|
|
}
|
|
}
|