c52ce4d497
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>
409 lines
16 KiB
Swift
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")
|
|
}
|
|
}
|