Files
Trey T c52ce4d497 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>
2026-06-05 16:26:50 -05:00

409 lines
16 KiB
Swift

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