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

1055 lines
44 KiB
Swift

import XCTest
/// Document CRUD UI test suite (non-warranty document lifecycle).
///
/// Merges `DocumentIntegrationTests` (DOC-002/004/005 + image-section stub,
/// integration against the real backend) with the generic document CRUD tests
/// from the former `Suite8_DocumentWarrantyTests` (create / edit / delete /
/// detail / search / filter / cancel / empty-state / edge-case coverage).
/// Warranty-specific scenarios live in `DocumentWarrantyUITests`.
///
/// Documents require a residence to create, so `requiresResidence = true` seeds
/// one "Precondition Home" residence before login (the app loads it on its
/// post-login fetch). Tests that view/edit/delete an EXISTING document seed
/// residence + document in `seedAccountPreconditions` (before login).
final class DocumentCRUDUITests: AuthenticatedUITestCase {
override var requiresResidence: Bool { true }
// MARK: - Preconditions
/// Documents seeded before login for the edit/delete/image integration tests.
/// A fresh account is empty at login, so these must be seeded here (before
/// login) rather than in the test body.
private(set) var editTargetDoc: TestDocument?
private(set) var deleteTargetDoc: TestDocument?
private(set) var imageSectionDoc: TestDocument?
override func seedAccountPreconditions(_ account: TestAccount) {
super.seedAccountPreconditions(account) // seeds the residence (requiresResidence)
guard let residence = seededResidence else { return }
editTargetDoc = account.seedDocument(
residenceId: residence.id,
title: "Edit Target Doc \(Int(Date().timeIntervalSince1970))",
documentType: "warranty"
)
deleteTargetDoc = account.seedDocument(
residenceId: residence.id,
title: "Delete Doc \(Int(Date().timeIntervalSince1970))",
documentType: "warranty"
)
imageSectionDoc = account.seedDocument(
residenceId: residence.id,
title: "Image Section Doc \(Int(Date().timeIntervalSince1970))",
documentType: "warranty"
)
}
// MARK: - Page Objects
private var docList: DocumentListScreen { DocumentListScreen(app: app) }
private var docForm: DocumentFormScreen { DocumentFormScreen(app: app) }
// MARK: - Suite8-style Helpers
/// Navigate to the Documents tab, load residence data into the DataManager
/// cache (so the property picker is populated), and prime the form once.
private func prepareDocumentsScreen() {
// Visit Residences tab to load residence data into DataManager cache
navigateToResidences()
pullToRefresh()
// Navigate to the Documents tab
navigateToDocuments()
// Open and close the document form once to prime the DataManager cache
// so the property picker is populated on subsequent opens.
let warmupAddButton = docList.addButton
if warmupAddButton.exists && warmupAddButton.isEnabled {
warmupAddButton.tap()
_ = docForm.titleField.waitForExistence(timeout: defaultTimeout)
cancelForm()
}
}
private func openDocumentForm(file: StaticString = #filePath, line: UInt = #line) {
let addButton = docList.addButton
XCTAssertTrue(addButton.exists && addButton.isEnabled, "Add button should exist and be enabled", file: file, line: line)
addButton.tap()
docForm.titleField.waitForExistenceOrFail(timeout: navigationTimeout, message: "Document form should appear", file: file, line: line)
}
private func fillTextEditor(text: String) {
let textEditor = app.textViews.firstMatch
if textEditor.exists {
textEditor.focusAndType(text, app: app)
}
}
/// Select a property from the residence picker. Fails the test if picker is missing or empty.
private func selectProperty(file: StaticString = #filePath, line: UInt = #line) {
// Look up the seeded residence name so we can match it by text in
// whichever picker variant iOS renders (menu, list, or wheel).
let residences = TestAccountAPIClient.listResidences(token: session.token) ?? []
let residenceName = residences.first?.name
let pickerButton = app.buttons[AccessibilityIdentifiers.Document.residencePicker].firstMatch
pickerButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Property picker should exist", file: file, line: line)
pickerButton.tap()
// Fast path: the residence option is often rendered as a plain Button
// or StaticText whose label is the residence name itself. Finding it
// by text works across menu, list, and wheel picker variants.
if let name = residenceName {
let byButton = app.buttons[name].firstMatch
if byButton.waitForExistence(timeout: 3) && byButton.isHittable {
byButton.tap()
_ = docForm.titleField.waitForExistence(timeout: navigationTimeout)
return
}
let byText = app.staticTexts[name].firstMatch
if byText.exists && byText.isHittable {
byText.tap()
_ = docForm.titleField.waitForExistence(timeout: navigationTimeout)
return
}
}
// SwiftUI Picker in Form renders either a menu (iOS 18+ default) or a
// pushed selection list. Detecting the menu requires a slightly longer
// wait because the dropdown animates in after the tap. Also: the form
// rows themselves are `cells`, so we can't use `cells.firstMatch` to
// detect list mode we must wait longer for a real menu before
// falling back.
let menuItem = app.menuItems.firstMatch
// Give the menu a bit longer to animate; 5s covers the usual case.
if menuItem.waitForExistence(timeout: 5) {
// Tap the last menu item (the residence option; the placeholder is
// index 0 and carries the "Select a Property" label).
let allItems = app.menuItems.allElementsBoundByIndex
let target = allItems.last ?? menuItem
if target.isHittable {
target.tap()
} else {
target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
// Ensure the menu actually dismissed; a lingering overlay blocks
// hit-testing on the form below.
_ = app.menuItems.firstMatch.waitForNonExistence(timeout: 2)
return
} else {
// List-style picker find a cell/row with a residence name.
// Cells can take a moment to become hittable during the push
// animation; retry the tap until the picker dismisses (titleField
// reappears on the form) or the attempt budget runs out.
let cells = app.cells
guard cells.firstMatch.waitForExistence(timeout: navigationTimeout) else {
XCTFail("No residence options appeared in picker", file: file, line: line)
return
}
let hittable = NSPredicate(format: "isHittable == true")
for attempt in 0..<5 {
let targetCell = cells.count > 1 ? cells.element(boundBy: 1) : cells.element(boundBy: 0)
guard targetCell.exists else {
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
continue
}
_ = XCTWaiter().wait(
for: [XCTNSPredicateExpectation(predicate: hittable, object: targetCell)],
timeout: 2.0 + Double(attempt)
)
if targetCell.isHittable {
targetCell.tap()
if docForm.titleField.waitForExistence(timeout: 2) { break }
}
// Reopen picker if it dismissed without selection.
if docForm.titleField.exists, attempt < 4, pickerButton.exists, pickerButton.isHittable {
pickerButton.tap()
_ = cells.firstMatch.waitForExistence(timeout: 3)
}
}
}
// Wait for picker to dismiss and return to form
_ = docForm.titleField.waitForExistence(timeout: navigationTimeout)
}
private func selectDocumentType(type: String) {
let typePicker = app.buttons[AccessibilityIdentifiers.Document.typePicker].firstMatch
if typePicker.exists {
typePicker.tap()
let typeButton = app.buttons[type]
if typeButton.waitForExistence(timeout: defaultTimeout) {
typeButton.tap()
} else {
// Try cells if it's a navigation style picker
let cells = app.cells
for i in 0..<cells.count {
let cell = cells.element(boundBy: i)
if cell.staticTexts[type].exists {
cell.tap()
break
}
}
}
}
}
private func submitForm(file: StaticString = #filePath, line: UInt = #line) {
// Dismiss keyboard by tapping outside form fields
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)).tap()
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
// If keyboard still showing (can happen with long text / autocorrect), try Return key
if app.keyboards.firstMatch.exists {
app.typeText("\n")
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
}
let submitButton = docForm.saveButton
if !submitButton.exists || !submitButton.isHittable {
app.swipeUp()
_ = submitButton.waitForExistence(timeout: navigationTimeout)
}
XCTAssertTrue(submitButton.exists && submitButton.isEnabled, "Submit button should exist and be enabled", file: file, line: line)
// First tap attempt
if submitButton.isHittable {
submitButton.tap()
} else {
submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
// Wait for form to dismiss retry tap if button doesn't disappear
if !submitButton.waitForNonExistence(timeout: loginTimeout) && submitButton.exists {
submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
_ = submitButton.waitForNonExistence(timeout: loginTimeout)
}
}
/// Look up a just-created document by title and track it for API cleanup.
private func trackDocumentForCleanup(title: String) {
if let items = TestAccountAPIClient.listDocuments(token: session.token),
let created = items.first(where: { $0.title.contains(title) }) {
cleaner.trackDocument(created.id)
}
}
private func cancelForm() {
let cancelButton = docForm.cancelButton
if cancelButton.exists {
cancelButton.tap()
_ = cancelButton.waitForNonExistence(timeout: defaultTimeout)
}
}
private func switchToWarrantiesTab() {
let warrantiesButton = app.buttons["Warranties"].firstMatch
if warrantiesButton.waitForExistence(timeout: navigationTimeout) {
warrantiesButton.tap()
return
}
// Fallback: segmented control button
app.segmentedControls.buttons["Warranties"].firstMatch.tap()
}
private func switchToDocumentsTab() {
let documentsButton = app.buttons["Documents"].firstMatch
if documentsButton.waitForExistence(timeout: navigationTimeout) {
documentsButton.tap()
return
}
// Fallback: segmented control button
app.segmentedControls.buttons["Documents"].firstMatch.tap()
}
private func searchFor(text: String) {
let searchField = app.searchFields.firstMatch
if searchField.exists {
searchField.focusAndType(text, app: app)
// Wait for search results to settle
_ = app.cells.firstMatch.waitForExistence(timeout: defaultTimeout)
}
}
private func clearSearch() {
let searchField = app.searchFields.firstMatch
if searchField.exists {
let clearButton = searchField.buttons["Clear text"]
if clearButton.exists {
clearButton.tap()
}
}
}
@discardableResult
private func applyFilter(filterName: String) -> Bool {
// Open filter menu via accessibility identifier
let filterButton = app.buttons[AccessibilityIdentifiers.Common.filterButton].firstMatch
guard filterButton.waitForExistence(timeout: defaultTimeout) else { return false }
filterButton.forceTap()
// Select filter option
let filterOption = app.buttons[filterName]
if filterOption.waitForExistence(timeout: defaultTimeout) {
filterOption.forceTap()
_ = filterOption.waitForNonExistence(timeout: defaultTimeout)
return true
}
// Try as static text (some menus render options as text)
let filterText = app.staticTexts[filterName]
if filterText.exists {
filterText.forceTap()
_ = filterText.waitForNonExistence(timeout: defaultTimeout)
return true
}
return false
}
// MARK: - Integration Helpers (DocumentIntegrationTests)
/// Navigate to the Documents tab and wait for it to load.
private func navigateToDocumentsAndPrepare() {
navigateToDocuments()
// Wait for the toolbar add-button (or empty-state / list) to confirm
// the Documents screen has loaded.
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch
let emptyState = app.otherElements[AccessibilityIdentifiers.Document.emptyStateView]
let documentList = app.otherElements[AccessibilityIdentifiers.Document.documentsList]
_ = addButton.waitForExistence(timeout: defaultTimeout)
|| emptyState.waitForExistence(timeout: 3)
|| documentList.waitForExistence(timeout: 3)
}
/// Pull-to-refresh on the Documents screen using absolute screen coordinates.
///
/// The Warranties tab shows a *horizontal* filter-chip ScrollView above the
/// content. `app.scrollViews.firstMatch` picks up the filter chips instead
/// of the content, so the base-class `pullToRefresh()` silently fails.
/// Working with app-level coordinates avoids this ambiguity.
private func pullToRefreshDocuments() {
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
start.press(forDuration: 0.3, thenDragTo: end)
// Wait for refresh indicator to appear and disappear
let refreshIndicator = app.activityIndicators.firstMatch
_ = refreshIndicator.waitForExistence(timeout: 3)
_ = refreshIndicator.waitForNonExistence(timeout: defaultTimeout)
}
/// Pull-to-refresh repeatedly until a target element appears or max retries
/// reached. Uses `pullToRefreshDocuments()` which targets the correct
/// scroll view on the Documents screen.
private func pullToRefreshDocumentsUntilVisible(_ element: XCUIElement, maxRetries: Int = 5) {
for _ in 0..<maxRetries {
if element.waitForExistence(timeout: 3) { return }
pullToRefreshDocuments()
}
// Final wait after last refresh
_ = element.waitForExistence(timeout: 5)
}
// MARK: - Navigation Tests (Suite8)
func test01_NavigateToDocumentsScreen() {
navigateToDocuments()
// Verify we're on documents screen by checking for the segmented control tabs
let warrantiesTab = app.buttons["Warranties"]
let documentsTab = app.buttons["Documents"]
let warrantiesExists = warrantiesTab.waitForExistence(timeout: navigationTimeout)
let documentsExists = documentsTab.waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(warrantiesExists || documentsExists, "Should see tab switcher on Documents screen")
}
func test02_SwitchBetweenWarrantiesAndDocuments() {
navigateToDocuments()
// Start on warranties tab
switchToWarrantiesTab()
// Switch to documents tab
switchToDocumentsTab()
// Switch back to warranties
switchToWarrantiesTab()
// Should not crash and tabs should still exist
let warrantiesTab = app.buttons["Warranties"]
XCTAssertTrue(warrantiesTab.exists, "Tabs should remain functional after switching")
}
// MARK: - Document Creation Tests (Suite8)
func test03_CreateDocumentWithAllFields() {
prepareDocumentsScreen()
switchToDocumentsTab()
openDocumentForm()
let testTitle = "Test Permit \(UUID().uuidString.prefix(8))"
// Fill required fields (document type no warranty fields needed)
selectProperty()
docForm.titleField.focusAndType(testTitle, app: app)
submitForm()
// Verify document appears in list
let documentCard = app.staticTexts[testTitle]
XCTAssertTrue(documentCard.waitForExistence(timeout: navigationTimeout), "Created document should appear in list")
}
func test04_CreateDocumentWithMinimalFields() {
prepareDocumentsScreen()
switchToDocumentsTab()
openDocumentForm()
let testTitle = "Min Doc \(UUID().uuidString.prefix(8))"
// Fill required fields (document type title + property)
selectProperty()
docForm.titleField.focusAndType(testTitle, app: app)
submitForm()
// Verify document appears
let documentCard = app.staticTexts[testTitle]
XCTAssertTrue(documentCard.waitForExistence(timeout: navigationTimeout), "Document with minimal fields should appear")
}
func test05_CreateDocumentWithEmptyTitle_ShouldFail() {
prepareDocumentsScreen()
switchToDocumentsTab()
openDocumentForm()
// Try to submit without title
selectProperty()
selectDocumentType(type: "Insurance")
let submitButton = app.buttons[AccessibilityIdentifiers.Document.saveButton].firstMatch
// Submit button should be disabled or show error
if submitButton.exists && submitButton.isEnabled {
submitButton.tap()
// Should show error message
let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'required' OR label CONTAINS[c] 'title'")).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: defaultTimeout), "Should show validation error for missing title")
}
cancelForm()
}
// MARK: - Search and Filter Tests (Suite8, document-side)
func test09_SearchDocumentsByTitle() {
prepareDocumentsScreen()
switchToDocumentsTab()
// Create a test document first
openDocumentForm()
let searchableTitle = "Searchable Doc \(UUID().uuidString.prefix(8))"
selectProperty()
docForm.titleField.focusAndType(searchableTitle, app: app)
selectDocumentType(type: "Insurance")
submitForm()
// Search for it
searchFor(text: String(searchableTitle.prefix(15)))
// Should find the document
let foundDocument = app.staticTexts[searchableTitle]
XCTAssertTrue(foundDocument.exists, "Should find document by search")
clearSearch()
}
func test11_FilterDocumentsByType() {
prepareDocumentsScreen()
switchToDocumentsTab()
// Apply type filter if filter button is not found, the test
// still passes (verifies no crash). Only assert when the filter was applied.
let filterApplied = applyFilter(filterName: "Permit")
if filterApplied {
// Should show filter indication
let filterChip = app.staticTexts["Permit"]
XCTAssertTrue(filterChip.exists || app.buttons["Permit"].exists, "Should show active type filter")
// Clear filter
applyFilter(filterName: "All Types")
}
// If filter was not applied (button not found), test passes no crash happened
}
// MARK: - Document Detail Tests (Suite8)
func test13_ViewDocumentDetail() {
prepareDocumentsScreen()
switchToDocumentsTab()
// Create a document
openDocumentForm()
let testTitle = "Detail Test Doc \(UUID().uuidString.prefix(8))"
selectProperty()
docForm.titleField.focusAndType(testTitle, app: app)
selectDocumentType(type: "Insurance")
fillTextEditor(text: "This is a test receipt with details")
submitForm()
// Tap on the document card
let documentCard = app.staticTexts[testTitle]
XCTAssertTrue(documentCard.waitForExistence(timeout: navigationTimeout), "Document should exist in list")
documentCard.tap()
// Should show detail screen
let detailTitle = app.staticTexts[testTitle]
XCTAssertTrue(detailTitle.waitForExistence(timeout: defaultTimeout), "Should show document detail screen")
// Go back
let backButton = app.navigationBars.buttons.firstMatch
backButton.tap()
}
// MARK: - Edit Tests (Suite8, document-side)
func test15_EditDocumentTitle() {
prepareDocumentsScreen()
switchToDocumentsTab()
// Create document
openDocumentForm()
let originalTitle = "Edit Test \(UUID().uuidString.prefix(8))"
selectProperty()
docForm.titleField.focusAndType(originalTitle, app: app)
selectDocumentType(type: "Insurance")
submitForm()
// Open detail
let documentCard = app.staticTexts[originalTitle]
XCTAssertTrue(documentCard.waitForExistence(timeout: navigationTimeout), "Document should exist")
documentCard.tap()
// Tap edit button
let editButton = app.buttons[AccessibilityIdentifiers.Document.editButton].firstMatch
if editButton.waitForExistence(timeout: defaultTimeout) {
editButton.tap()
// Change title using the accessibility identifier
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField].firstMatch
if titleField.waitForExistence(timeout: defaultTimeout) {
let newTitle = "Edited \(originalTitle)"
titleField.clearAndEnterText(newTitle, app: app)
submitForm()
// Verify new title appears
let updatedTitle = app.staticTexts[newTitle]
XCTAssertTrue(updatedTitle.waitForExistence(timeout: navigationTimeout), "Updated title should appear")
}
}
// Go back to list
app.navigationBars.buttons.element(boundBy: 0).tap()
}
// MARK: - Delete Tests (Suite8, document-side)
func test17_DeleteDocument() {
prepareDocumentsScreen()
switchToDocumentsTab()
// Create document to delete
openDocumentForm()
let deleteTitle = "To Delete \(UUID().uuidString.prefix(8))"
selectProperty()
docForm.titleField.focusAndType(deleteTitle, app: app)
selectDocumentType(type: "Insurance")
submitForm()
// Open detail
let documentCard = app.staticTexts[deleteTitle]
XCTAssertTrue(documentCard.waitForExistence(timeout: navigationTimeout), "Document should exist")
documentCard.tap()
// Find and tap delete button
let deleteButton = app.buttons[AccessibilityIdentifiers.Document.deleteButton].firstMatch
if deleteButton.waitForExistence(timeout: defaultTimeout) {
deleteButton.tap()
// Confirm deletion
let confirmButton = app.alerts.buttons["Delete"].firstMatch
if confirmButton.waitForExistence(timeout: defaultTimeout) {
confirmButton.tap()
}
// Wait for navigation back to list
_ = app.navigationBars.firstMatch.waitForExistence(timeout: defaultTimeout)
// Verify document no longer exists
let deletedCard = app.staticTexts[deleteTitle]
XCTAssertTrue(deletedCard.waitForNonExistence(timeout: defaultTimeout), "Deleted document should not appear in list")
}
}
// MARK: - Edge Cases and Error Handling (Suite8, document-side)
func test19_CancelDocumentCreation() {
prepareDocumentsScreen()
switchToDocumentsTab()
openDocumentForm()
// Fill some fields
selectProperty()
docForm.titleField.focusAndType("Cancelled Document", app: app)
selectDocumentType(type: "Insurance")
// Cancel instead of save
cancelForm()
// Should not appear in list
let cancelledDoc = app.staticTexts["Cancelled Document"]
XCTAssertFalse(cancelledDoc.exists, "Cancelled document should not be created")
}
func test20_HandleEmptyDocumentsList() {
prepareDocumentsScreen()
switchToDocumentsTab()
// Apply very specific filter to get empty list
searchFor(text: "NONEXISTENT_DOCUMENT_12345")
// Should show empty state or no items
let emptyState = app.otherElements[AccessibilityIdentifiers.Document.emptyStateView]
_ = emptyState.waitForExistence(timeout: defaultTimeout)
let hasNoItems = app.cells.count == 0
XCTAssertTrue(emptyState.exists || hasNoItems, "Should handle empty documents list gracefully")
clearSearch()
}
func test22_CreateDocumentWithLongTitle() {
prepareDocumentsScreen()
switchToDocumentsTab()
openDocumentForm()
let longTitle = "This is a very long document title that exceeds normal length expectations to test how the UI handles lengthy text input " + UUID().uuidString
selectProperty()
docForm.titleField.focusAndType(longTitle, app: app)
selectDocumentType(type: "Insurance")
submitForm()
// Track via API (also gives server time to process)
trackDocumentForCleanup(title: longTitle)
// Re-navigate to refresh the list after creation
navigateToDocuments()
switchToDocumentsTab()
// Verify it was created (partial match with wait)
let partialTitle = String(longTitle.prefix(30))
let documentCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(partialTitle)'")).firstMatch
XCTAssertTrue(documentCard.waitForExistence(timeout: loginTimeout), "Document with long title should be created")
}
func test24_RapidTabSwitching() {
navigateToDocuments()
// Rapidly switch between tabs
for _ in 0..<5 {
switchToWarrantiesTab()
switchToDocumentsTab()
}
// Should remain stable
let warrantiesTab = app.buttons["Warranties"]
let documentsTab = app.buttons["Documents"]
XCTAssertTrue(warrantiesTab.exists && documentsTab.exists, "Should handle rapid tab switching without crashing")
}
// MARK: - DOC-002: Create Document (integration)
func testDOC002_CreateDocumentWithRequiredFields() {
// The precondition residence (requiresResidence) gives the picker an option.
navigateToDocumentsAndPrepare()
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch
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()
}
// Wait for the form to load
let residencePicker0 = app.buttons[AccessibilityIdentifiers.Document.residencePicker]
_ = residencePicker0.waitForExistence(timeout: defaultTimeout)
// Select a residence from the picker (required for documents created from Documents tab).
// SwiftUI Picker with menu style: tapping opens a dropdown menu with options as buttons.
let residences = TestAccountAPIClient.listResidences(token: session.token) ?? []
let residenceName = residences.first?.name ?? (seededResidence?.name ?? "Precondition Home")
let residencePicker = app.buttons[AccessibilityIdentifiers.Document.residencePicker]
let pickerByLabel = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Property' OR label CONTAINS[c] 'Residence' OR label CONTAINS[c] 'Select'")
).firstMatch
let pickerElement = residencePicker.waitForExistence(timeout: defaultTimeout) ? residencePicker : pickerByLabel
if pickerElement.waitForExistence(timeout: defaultTimeout) {
pickerElement.forceTap()
// Menu-style picker shows options as buttons
let residenceButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] %@", residenceName)
).firstMatch
if residenceButton.waitForExistence(timeout: 5) {
residenceButton.tap()
} else {
// Fallback: tap any hittable option that's not the placeholder
let anyOption = app.buttons.allElementsBoundByIndex.first(where: {
$0.exists && $0.isHittable &&
!$0.label.isEmpty &&
!$0.label.lowercased().contains("select") &&
!$0.label.lowercased().contains("cancel")
})
anyOption?.tap()
}
}
// Fill in the title field
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
let uniqueTitle = "IntTest Doc \(Int(Date().timeIntervalSince1970))"
titleField.forceTap()
titleField.typeText(uniqueTitle)
// Dismiss keyboard by tapping Return key (coordinate tap doesn't reliably defocus)
let returnKey = app.keyboards.buttons["Return"]
if returnKey.waitForExistence(timeout: 3) {
returnKey.tap()
} else {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap()
}
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
// The default document type is "warranty" (opened from Warranties tab), which requires
// Item Name and Provider/Company fields. Swipe up to reveal them.
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
let itemNameField = app.textFields["Item Name"]
// Swipe up to reveal warranty fields below the fold
for _ in 0..<3 {
if itemNameField.exists && itemNameField.isHittable { break }
if scrollContainer.exists { scrollContainer.swipeUp() }
_ = itemNameField.waitForExistence(timeout: 2)
}
if itemNameField.waitForExistence(timeout: 5) {
// Tap directly to get keyboard focus (not forceTap which uses coordinate)
if itemNameField.isHittable {
itemNameField.tap()
} else {
itemNameField.forceTap()
// If forceTap didn't give focus, tap coordinate again
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
itemNameField.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
itemNameField.typeText("Test Item")
// Dismiss keyboard
if returnKey.exists { returnKey.tap() }
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
}
let providerField = app.textFields["Provider/Company"]
for _ in 0..<3 {
if providerField.exists && providerField.isHittable { break }
if scrollContainer.exists { scrollContainer.swipeUp() }
_ = providerField.waitForExistence(timeout: 2)
}
if providerField.waitForExistence(timeout: 5) {
if providerField.isHittable {
providerField.tap()
} else {
providerField.forceTap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
providerField.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
providerField.typeText("Test Provider")
// Dismiss keyboard
if returnKey.exists { returnKey.tap() }
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
}
// Save the document swipe up to reveal save button if needed
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
for _ in 0..<3 {
if saveButton.exists && saveButton.isHittable { break }
if scrollContainer.exists { scrollContainer.swipeUp() }
_ = saveButton.waitForExistence(timeout: 2)
}
saveButton.forceTap()
// Wait for the form to dismiss and the new document to appear in the list.
let newDoc = app.staticTexts[uniqueTitle]
if !newDoc.waitForExistence(timeout: defaultTimeout) {
pullToRefreshDocumentsUntilVisible(newDoc, maxRetries: 3)
}
XCTAssertTrue(
newDoc.waitForExistence(timeout: loginTimeout),
"Newly created document should appear in list"
)
}
// MARK: - DOC-004: Edit Document (integration)
func testDOC004_EditDocument() {
// Document was seeded before login in seedAccountPreconditions.
guard let doc = editTargetDoc else {
XCTFail("Edit target document was not seeded")
return
}
navigateToDocumentsAndPrepare()
// Pull to refresh until the seeded document is visible
let card = app.staticTexts[doc.title]
pullToRefreshDocumentsUntilVisible(card)
card.waitForExistenceOrFail(timeout: loginTimeout)
card.forceTap()
// Tap the ellipsis menu to reveal edit/delete options
let menuButton = app.buttons[AccessibilityIdentifiers.Document.menuButton]
let menuImage = app.images[AccessibilityIdentifiers.Document.menuButton]
if menuButton.waitForExistence(timeout: 5) {
menuButton.forceTap()
} else if menuImage.waitForExistence(timeout: 3) {
menuImage.forceTap()
} else {
let navBarMenu = app.navigationBars.buttons.element(boundBy: app.navigationBars.buttons.count - 1)
navBarMenu.waitForExistenceOrFail(timeout: 5)
navBarMenu.forceTap()
}
// Tap edit
let editButton = app.buttons[AccessibilityIdentifiers.Document.editButton]
if !editButton.waitForExistence(timeout: defaultTimeout) {
let anyEdit = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Edit'")
).firstMatch
anyEdit.waitForExistenceOrFail(timeout: 5)
anyEdit.forceTap()
} else {
editButton.forceTap()
}
// Update title clear existing text first using delete keys
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
titleField.forceTap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
// Delete all existing text character by character (use generous count)
let currentValue = (titleField.value as? String) ?? ""
let deleteCount = max(currentValue.count, 50) + 5
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: deleteCount)
titleField.typeText(deleteString)
let updatedTitle = "Updated Doc \(Int(Date().timeIntervalSince1970))"
titleField.typeText(updatedTitle)
// Verify the text field now contains the updated title
let fieldValue = titleField.value as? String ?? ""
if !fieldValue.contains("Updated Doc") {
XCTFail("Title field text replacement failed. Current value: '\(fieldValue)'. Expected to contain: 'Updated Doc'")
return
}
// Dismiss keyboard so save button is hittable
let returnKey = app.keyboards.buttons["Return"]
if returnKey.exists { returnKey.tap() }
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
if !saveButton.isHittable {
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
if scrollContainer.exists { scrollContainer.swipeUp() }
_ = saveButton.waitForExistence(timeout: defaultTimeout)
}
saveButton.forceTap()
// After save, the form pops back to the detail view.
_ = titleField.waitForNonExistence(timeout: loginTimeout)
// Navigate back: tap the back button in nav bar to return to list
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.waitForExistence(timeout: defaultTimeout) {
backButton.tap()
}
// Tap back again if we're still on detail view
let secondBack = app.navigationBars.buttons.element(boundBy: 0)
if secondBack.exists && !app.tabBars.firstMatch.buttons.firstMatch.isSelected {
secondBack.tap()
}
// Pull to refresh to ensure the list shows the latest data.
let updatedText = app.staticTexts[updatedTitle]
pullToRefreshDocumentsUntilVisible(updatedText)
// Extra retries DataManager mutation propagation can be slow
for _ in 0..<3 {
if updatedText.waitForExistence(timeout: 5) { break }
pullToRefresh()
}
// The UI may not reflect the edit immediately due to DataManager cache timing.
// Accept the edit if the title field contained the right value (verified above).
if !updatedText.exists {
// Verify the original title is at least still visible (we're on the right screen)
let originalCard = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Edit Target Doc'")
).firstMatch
if originalCard.exists {
// Edit saved (field value was verified) but list didn't refresh not a test bug
return
}
}
XCTAssertTrue(updatedText.exists, "Updated document title should appear after edit")
}
// MARK: - DOC-007: Document Image Section Exists (integration)
// NOTE: Full image-deletion testing (the original DOC-007 scenario) requires a
// document with at least one uploaded image. Image upload cannot be triggered
// via API alone it requires user interaction with the photo picker inside the
// app (or a multipart upload endpoint). This stub seeds a document, opens its
// detail view, and verifies the images section is present so that a human tester
// or future automation (with photo injection) can extend it.
func test22_documentImageSectionExists() throws {
// Document was seeded before login in seedAccountPreconditions.
guard let document = imageSectionDoc else {
throw XCTSkip("Image section document was not seeded")
}
navigateToDocumentsAndPrepare()
// Pull to refresh until the seeded document is visible
let docText = app.staticTexts[document.title]
pullToRefreshDocumentsUntilVisible(docText)
docText.waitForExistenceOrFail(timeout: loginTimeout)
docText.forceTap()
// Verify the detail view loaded
let detailView = app.otherElements[AccessibilityIdentifiers.Document.detailView]
let detailLoaded = detailView.waitForExistence(timeout: defaultTimeout)
|| app.navigationBars.staticTexts[document.title].waitForExistence(timeout: defaultTimeout)
guard detailLoaded else {
throw XCTSkip("Document detail view did not load — document may not be visible after API seeding")
}
// Look for an images / photos section header or add-image button.
let imagesSection = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Image' OR label CONTAINS[c] 'Photo' OR label CONTAINS[c] 'Attachment'")
).firstMatch
let addImageButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Image' OR label CONTAINS[c] 'Photo' OR label CONTAINS[c] 'Add'")
).firstMatch
let sectionVisible = imagesSection.waitForExistence(timeout: defaultTimeout)
|| addImageButton.waitForExistence(timeout: 3)
if !sectionVisible {
throw XCTSkip(
"Document detail does not yet show an images/photos section — see DOC-007 in test plan."
)
}
}
// MARK: - DOC-005: Delete Document (integration)
func testDOC005_DeleteDocument() {
// Document was seeded before login in seedAccountPreconditions.
guard let doc = deleteTargetDoc else {
XCTFail("Delete target document was not seeded")
return
}
let deleteTitle = doc.title
navigateToDocumentsAndPrepare()
// Pull to refresh until the seeded document is visible
let target = app.staticTexts[deleteTitle]
pullToRefreshDocumentsUntilVisible(target)
target.waitForExistenceOrFail(timeout: loginTimeout)
target.forceTap()
// Tap the ellipsis menu to reveal delete option
let deleteMenuButton = app.buttons[AccessibilityIdentifiers.Document.menuButton]
let deleteMenuImage = app.images[AccessibilityIdentifiers.Document.menuButton]
if deleteMenuButton.waitForExistence(timeout: 5) {
deleteMenuButton.forceTap()
} else if deleteMenuImage.waitForExistence(timeout: 3) {
deleteMenuImage.forceTap()
} else {
let navBarMenu = app.navigationBars.buttons.element(boundBy: app.navigationBars.buttons.count - 1)
navBarMenu.waitForExistenceOrFail(timeout: 5)
navBarMenu.forceTap()
}
let deleteButton = app.buttons[AccessibilityIdentifiers.Document.deleteButton]
if !deleteButton.waitForExistence(timeout: defaultTimeout) {
let anyDelete = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Delete'")
).firstMatch
anyDelete.waitForExistenceOrFail(timeout: 5)
anyDelete.forceTap()
} else {
deleteButton.forceTap()
}
let confirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
let alertDelete = app.alerts.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
).firstMatch
if confirmButton.waitForExistence(timeout: defaultTimeout) {
confirmButton.tap()
} else if alertDelete.waitForExistence(timeout: defaultTimeout) {
alertDelete.tap()
}
let deletedDoc = app.staticTexts[deleteTitle]
XCTAssertTrue(
deletedDoc.waitForNonExistence(timeout: loginTimeout),
"Deleted document should no longer appear"
)
}
}