Re-architect iOS XCUITest suite: per-test isolation + domain organization
Migrate the XCUITest suite off the legacy shared-account model (and the prior Django-style auth assumptions) to a parallel-safe, domain-organized architecture, validated end-to-end against the live Kratos stack. Isolation (parallel-safe by construction): - Core/Fixtures/TestAccount.swift: each test mints its own pre-verified Kratos identity (uit_<domain>_<uuid>@test.honeydue.local), logs in, seeds under its own token, and deletes the identity in teardown (cascading all data + clearing Kratos). No shared testuser; parallel workers no longer race. - AuthenticatedUITestCase rewritten to that model (member surface preserved); adds requiresResidence / seedAccountPreconditions to seed UI-gated data BEFORE login (a fresh account is empty at login). Organization (255 tests preserved, none dropped): - 21 domain suites under Auth/ Onboarding/ Residence/ Task/ Contractor/ Document/ Sharing/ Navigation/ Smoke/ CrossCutting/ E2E/, consistent <Domain>UITests naming. Removes the Suite1..11 / AAA_ / ZZ_ / Tests/Rebuild naming chaos and the overlapping task/residence/auth suites. Runner + test plans: - run_ui_tests.sh: Smoke gate -> Seed -> Parallel(8 workers) -> Sweep. The parallel phase runs the whole target minus phase-managed suites via -skip-testing, so new suites auto-include (no hand-maintained list to drift). Drops the 2-worker cap and Suite6 isolation (isolation made them moot). - HoneyDueUITests.xctestplan skips the 4 phase-managed suites; adds Smoke.xctestplan. Kratos auth fixes folded in (login/verify/reset endpoints removed under Kratos): real Mailpit verification codes replace the obsolete fixed "123456"; teardown deletes Kratos identities; admin-panel login uses the correct seeded password. Build green; isolation, parallelism, and the precondition/sharing migrations validated against the live stack (0 leaked accounts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
import XCTest
|
||||
|
||||
/// Task create/read/update/delete UI tests.
|
||||
///
|
||||
/// Merged from the former `Suite5_TaskTests` and `Tests/TaskIntegrationTests`.
|
||||
/// Per-test isolation is provided by `AuthenticatedUITestCase`: every test mints
|
||||
/// a fresh account, logs in, and tears it down. Task creation gates on a residence
|
||||
/// existing, so `requiresResidence` seeds one BEFORE login (the fresh account is
|
||||
/// otherwise empty and the Add-Task button would stay disabled).
|
||||
///
|
||||
/// Tests that must SEE a pre-existing task (uncancel flows) seed that task in
|
||||
/// `seedAccountPreconditions` so the app loads it on its post-login fetch.
|
||||
final class TaskCRUDUITests: AuthenticatedUITestCase {
|
||||
|
||||
// Task creation gates on a residence existing; seed one before login so the
|
||||
// fresh account's app sees it (otherwise the Add-Task button stays disabled).
|
||||
override var requiresResidence: Bool { true }
|
||||
|
||||
// MARK: - Preconditions
|
||||
|
||||
/// Cancelled task seeded before login for the uncancel flows. A fresh account
|
||||
/// is empty at login, so a task seeded in the test body would be invisible to
|
||||
/// the app without a manual refresh — seed it here instead.
|
||||
private(set) var seededCancelledTask_uncancelFlow: TestTask?
|
||||
private(set) var seededCancelledTask_uncancelV2: TestTask?
|
||||
|
||||
override func seedAccountPreconditions(_ account: TestAccount) {
|
||||
super.seedAccountPreconditions(account) // seeds seededResidence (requiresResidence)
|
||||
guard let residence = seededResidence else { return }
|
||||
|
||||
// TASK-010: a cancelled task that the test will uncancel/reopen.
|
||||
seededCancelledTask_uncancelFlow = TestDataSeeder.createCancelledTask(
|
||||
token: account.token,
|
||||
residenceId: residence.id
|
||||
)
|
||||
|
||||
// TASK-010 (v2): a named residence+task, cancelled, that the test restores.
|
||||
let v2Task = account.seedTask(
|
||||
residenceId: residence.id,
|
||||
title: "Uncancel Me \(Int(Date().timeIntervalSince1970))"
|
||||
)
|
||||
seededCancelledTask_uncancelV2 = TestAccountAPIClient.cancelTask(token: account.token, id: v2Task.id) ?? v2Task
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
// Dismiss any open form from a previous test
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
||||
if cancelButton.exists { cancelButton.tap() }
|
||||
|
||||
navigateToTasks()
|
||||
// Wait for task screen to load
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
addButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Task add button should appear")
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
func test01_cancelTaskCreation() {
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
addButton.tap()
|
||||
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task form should open")
|
||||
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
||||
cancelButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Cancel button should exist")
|
||||
cancelButton.tap()
|
||||
|
||||
// Verify we're back on the task list
|
||||
let addButtonAgain = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
XCTAssertTrue(addButtonAgain.waitForExistence(timeout: navigationTimeout), "Should be back on tasks list after cancel")
|
||||
}
|
||||
|
||||
// MARK: - View/List
|
||||
|
||||
func test02_tasksTabExists() {
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
XCTAssertTrue(tabBar.exists, "Tab bar should exist")
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
XCTAssertTrue(addButton.exists, "Task add button should exist (proves we're on Tasks tab)")
|
||||
}
|
||||
|
||||
func test03_viewTasksList() {
|
||||
// Tasks screen should show — verified by the add button existence from setUp
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
XCTAssertTrue(addButton.exists, "Tasks screen should be visible with add button")
|
||||
}
|
||||
|
||||
func test04_addTaskButtonEnabled() {
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
XCTAssertTrue(addButton.isEnabled, "Task add button should be enabled when residence exists")
|
||||
}
|
||||
|
||||
func test05_navigateToAddTask() {
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
addButton.tap()
|
||||
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task title field should appear in add form")
|
||||
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
||||
XCTAssertTrue(saveButton.exists, "Save button should exist in add task form")
|
||||
|
||||
// Clean up: dismiss form
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
||||
if cancelButton.exists { cancelButton.tap() }
|
||||
}
|
||||
|
||||
// MARK: - Creation
|
||||
|
||||
func test06_createBasicTask() {
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
addButton.tap()
|
||||
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
|
||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task title field should appear")
|
||||
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let taskTitle = "UITest Task \(timestamp)"
|
||||
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: taskTitle)
|
||||
|
||||
dismissKeyboard()
|
||||
app.swipeUp()
|
||||
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
||||
saveButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Save button should exist")
|
||||
saveButton.tap()
|
||||
|
||||
// Wait for form to dismiss
|
||||
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
|
||||
|
||||
// Verify task was created via API (also gives the server time to process)
|
||||
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
||||
let created = items.first(where: { $0.title.contains(taskTitle) }) {
|
||||
cleaner.trackTask(created.id)
|
||||
}
|
||||
|
||||
// Navigate to tasks tab and refresh to pick up the newly created task
|
||||
navigateToTasks()
|
||||
refreshTasks()
|
||||
let taskListScreen = TaskListScreen(app: app)
|
||||
let newTask = taskListScreen.findTask(title: taskTitle)
|
||||
XCTAssertTrue(newTask.waitForExistence(timeout: loginTimeout), "New task '\(taskTitle)' should appear in the list")
|
||||
}
|
||||
|
||||
func testTASK_CreateTaskAppearsInList() {
|
||||
// Residence is seeded before login (requiresResidence) so task creation
|
||||
// has a valid target.
|
||||
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: - View Details
|
||||
|
||||
func test07_viewTaskDetails() {
|
||||
// Create a task first
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let taskTitle = "UITest Detail \(timestamp)"
|
||||
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
addButton.tap()
|
||||
|
||||
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: taskTitle)
|
||||
dismissKeyboard()
|
||||
app.swipeUp()
|
||||
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
|
||||
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
saveButton.tap()
|
||||
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
|
||||
|
||||
// Verify task was created via API (also gives the server time to process)
|
||||
if let items = TestAccountAPIClient.listTasks(token: session.token),
|
||||
let created = items.first(where: { $0.title.contains(taskTitle) }) {
|
||||
cleaner.trackTask(created.id)
|
||||
}
|
||||
|
||||
// Navigate to tasks tab and refresh to pick up the newly created task
|
||||
navigateToTasks()
|
||||
refreshTasks()
|
||||
let taskListScreen = TaskListScreen(app: app)
|
||||
let taskCard = taskListScreen.findTask(title: taskTitle)
|
||||
taskCard.waitForExistenceOrFail(timeout: loginTimeout, message: "Created task should appear in list")
|
||||
|
||||
// Verify the task card is accessible and the actions menu exists
|
||||
// (There is no task detail screen — cards are self-contained with a context menu)
|
||||
let actionsMenu = app.buttons["Task actions"].firstMatch
|
||||
XCTAssertTrue(actionsMenu.waitForExistence(timeout: navigationTimeout), "Task actions menu should be accessible")
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
func test08_navigateToContractors() {
|
||||
navigateToContractors()
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton].firstMatch
|
||||
XCTAssertTrue(addButton.waitForExistence(timeout: navigationTimeout), "Contractors screen should load")
|
||||
}
|
||||
|
||||
func test09_navigateToDocuments() {
|
||||
navigateToDocuments()
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch
|
||||
XCTAssertTrue(addButton.waitForExistence(timeout: navigationTimeout), "Documents screen should load")
|
||||
}
|
||||
|
||||
func test10_navigateBetweenTabs() {
|
||||
navigateToResidences()
|
||||
let resAddButton = app.buttons[AccessibilityIdentifiers.Residence.addButton].firstMatch
|
||||
XCTAssertTrue(resAddButton.waitForExistence(timeout: navigationTimeout), "Residences screen should load")
|
||||
|
||||
navigateToTasks()
|
||||
let taskAddButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
XCTAssertTrue(taskAddButton.waitForExistence(timeout: navigationTimeout), "Tasks screen should load after navigating back")
|
||||
}
|
||||
|
||||
// MARK: - TASK-010: Uncancel Task
|
||||
|
||||
func testTASK010_UncancelTaskFlow() throws {
|
||||
// Cancelled task was seeded BEFORE login (seedAccountPreconditions) so the
|
||||
// app's post-login fetch already has it.
|
||||
guard let cancelledTask = seededCancelledTask_uncancelFlow else {
|
||||
throw XCTSkip("Cancelled task precondition was not seeded")
|
||||
}
|
||||
|
||||
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 {
|
||||
// Residence + cancelled task were seeded BEFORE login
|
||||
// (seedAccountPreconditions) so the app loads them on its post-login fetch.
|
||||
guard let task = seededCancelledTask_uncancelV2 else {
|
||||
throw XCTSkip("Cancelled task precondition was not seeded")
|
||||
}
|
||||
|
||||
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).
|
||||
// Residence is seeded before login (requiresResidence).
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import XCTest
|
||||
|
||||
/// Comprehensive task lifecycle tests: status, complete, cancel, uncancel,
|
||||
/// recurrence, and edge-case creation/edit variations.
|
||||
///
|
||||
/// Migrated from the former `Suite6_ComprehensiveTaskTests`. Per-test isolation
|
||||
/// is provided by `AuthenticatedUITestCase` (fresh account per test). Task
|
||||
/// creation gates on a residence existing, so `requiresResidence` seeds one
|
||||
/// BEFORE login (the fresh account is otherwise empty and the Add-Task button
|
||||
/// would stay disabled). Every test here creates its tasks via the UI, so no
|
||||
/// pre-seeded tasks are needed.
|
||||
///
|
||||
/// Test Order (least to most complex):
|
||||
/// 1. Error/incomplete data tests
|
||||
/// 2. Creation tests
|
||||
/// 3. Edit/update tests
|
||||
/// 4. Navigation/view tests
|
||||
/// 5. Persistence tests
|
||||
final class TaskLifecycleUITests: AuthenticatedUITestCase {
|
||||
|
||||
// Task creation gates on a residence existing; seed one before login so the
|
||||
// fresh account's app sees it (otherwise the Add-Task button stays disabled).
|
||||
override var requiresResidence: Bool { true }
|
||||
|
||||
// Test data tracking
|
||||
var createdTaskTitles: [String] = []
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
// Dismiss any open form from a previous test
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
|
||||
if cancelButton.exists { cancelButton.tap() }
|
||||
|
||||
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 {
|
||||
createdTaskTitles.removeAll()
|
||||
// Account deletion in super cascades all seeded/created data — no manual
|
||||
// task cleanup needed.
|
||||
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 the 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.
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user