Rearchitect UI test suite for complete, non-flaky coverage against live API
- Migrate Suite4-10, SmokeTests, NavigationCriticalPathTests to AuthenticatedTestCase with seeded admin account and real backend login - Add 34 accessibility identifiers across 11 app views (task completion, profile, notifications, theme, join residence, manage users, forms) - Create FeatureCoverageTests (14 tests) covering previously untested features: profile edit, theme selection, notification prefs, task completion, manage users, join residence, task templates - Create MultiUserSharingTests (18 API tests) and MultiUserSharingUITests (8 XCUI tests) for full cross-user residence sharing lifecycle - Add cleanup infrastructure: SuiteZZ_CleanupTests auto-wipes test data after runs, cleanup_test_data.sh script for manual reset via admin API - Add share code API methods to TestAccountAPIClient (generateShareCode, joinWithCode, getShareCode, listResidenceUsers, removeUser) - Fix app bugs found by tests: - ResidencesListView join callback now uses forceRefresh:true - APILayer invalidates task cache when residence count changes - AllTasksView auto-reloads tasks when residence list changes - Fix test quality: keyboard focus waits, Save/Add button label matching, Documents tab label (Docs), remove API verification from UI tests - DataLayerTests and PasswordResetTests now verify through UI, not API calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,12 +11,12 @@ final class DocumentIntegrationTests: AuthenticatedTestCase {
|
||||
// MARK: - DOC-002: Create Document
|
||||
|
||||
func testDOC002_CreateDocumentWithRequiredFields() {
|
||||
// Seed a residence so the document form has a valid residence picker
|
||||
cleaner.seedResidence()
|
||||
// Seed a residence so the picker has an option to select
|
||||
let residence = cleaner.seedResidence(name: "DocTest Residence \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
navigateToDocuments()
|
||||
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton]
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch
|
||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Document.emptyStateView]
|
||||
let documentList = app.otherElements[AccessibilityIdentifiers.Document.documentsList]
|
||||
|
||||
@@ -35,16 +35,119 @@ final class DocumentIntegrationTests: AuthenticatedTestCase {
|
||||
emptyAddButton.forceTap()
|
||||
}
|
||||
|
||||
// Wait for the form to load
|
||||
sleep(2)
|
||||
|
||||
// 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 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()
|
||||
sleep(1)
|
||||
|
||||
// Menu-style picker shows options as buttons
|
||||
let residenceButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] %@", residence.name)
|
||||
).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()
|
||||
}
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
sleep(1)
|
||||
|
||||
// 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() }
|
||||
sleep(1)
|
||||
}
|
||||
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
|
||||
usleep(500000)
|
||||
itemNameField.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
usleep(500000)
|
||||
itemNameField.typeText("Test Item")
|
||||
|
||||
// Dismiss keyboard
|
||||
if returnKey.exists { returnKey.tap() }
|
||||
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
let providerField = app.textFields["Provider/Company"]
|
||||
for _ in 0..<3 {
|
||||
if providerField.exists && providerField.isHittable { break }
|
||||
if scrollContainer.exists { scrollContainer.swipeUp() }
|
||||
sleep(1)
|
||||
}
|
||||
if providerField.waitForExistence(timeout: 5) {
|
||||
if providerField.isHittable {
|
||||
providerField.tap()
|
||||
} else {
|
||||
providerField.forceTap()
|
||||
usleep(500000)
|
||||
providerField.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
usleep(500000)
|
||||
providerField.typeText("Test Provider")
|
||||
|
||||
// Dismiss keyboard
|
||||
if returnKey.exists { returnKey.tap() }
|
||||
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Save the document — swipe up to reveal save button if needed
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
|
||||
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
for _ in 0..<3 {
|
||||
if saveButton.exists && saveButton.isHittable { break }
|
||||
if scrollContainer.exists { scrollContainer.swipeUp() }
|
||||
sleep(1)
|
||||
}
|
||||
saveButton.forceTap()
|
||||
|
||||
// Wait for the form to dismiss and the new document to appear in the list
|
||||
let newDoc = app.staticTexts[uniqueTitle]
|
||||
XCTAssertTrue(
|
||||
newDoc.waitForExistence(timeout: longTimeout),
|
||||
@@ -55,43 +158,106 @@ final class DocumentIntegrationTests: AuthenticatedTestCase {
|
||||
// MARK: - DOC-004: Edit Document
|
||||
|
||||
func testDOC004_EditDocument() {
|
||||
// Seed a residence and document via API
|
||||
// Seed a residence and document via API (use "warranty" type since default tab is Warranties)
|
||||
let residence = cleaner.seedResidence()
|
||||
let doc = cleaner.seedDocument(residenceId: residence.id, title: "Edit Target Doc \(Int(Date().timeIntervalSince1970))")
|
||||
let doc = cleaner.seedDocument(residenceId: residence.id, title: "Edit Target Doc \(Int(Date().timeIntervalSince1970))", documentType: "warranty")
|
||||
|
||||
navigateToDocuments()
|
||||
|
||||
// Find and tap the seeded document
|
||||
// Pull to refresh until the seeded document is visible
|
||||
let card = app.staticTexts[doc.title]
|
||||
pullToRefreshUntilVisible(card)
|
||||
card.waitForExistenceOrFail(timeout: longTimeout)
|
||||
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]
|
||||
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
editButton.forceTap()
|
||||
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
|
||||
// Update title — clear existing text first using delete keys
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField]
|
||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
titleField.forceTap()
|
||||
titleField.press(forDuration: 1.0)
|
||||
let selectAll = app.menuItems["Select All"]
|
||||
if selectAll.waitForExistence(timeout: 2) {
|
||||
selectAll.tap()
|
||||
}
|
||||
sleep(1)
|
||||
|
||||
// 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() }
|
||||
sleep(1)
|
||||
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
|
||||
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
if !saveButton.isHittable {
|
||||
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
|
||||
if scrollContainer.exists { scrollContainer.swipeUp() }
|
||||
sleep(1)
|
||||
}
|
||||
saveButton.forceTap()
|
||||
|
||||
// After save, the form pops back to the detail view.
|
||||
// Wait for form to dismiss, then navigate back to the list.
|
||||
sleep(3)
|
||||
|
||||
// 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: 5) {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
// 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()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Pull to refresh to ensure the list shows the latest data
|
||||
pullToRefresh()
|
||||
|
||||
// Debug: dump visible texts to see what's showing
|
||||
let visibleTexts = app.staticTexts.allElementsBoundByIndex.prefix(20).map { $0.label }
|
||||
|
||||
let updatedText = app.staticTexts[updatedTitle]
|
||||
XCTAssertTrue(
|
||||
updatedText.waitForExistence(timeout: longTimeout),
|
||||
"Updated document title should appear after edit"
|
||||
"Updated document title should appear after edit. Visible texts: \(visibleTexts)"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,13 +274,15 @@ final class DocumentIntegrationTests: AuthenticatedTestCase {
|
||||
let residence = cleaner.seedResidence()
|
||||
let document = cleaner.seedDocument(
|
||||
residenceId: residence.id,
|
||||
title: "Image Section Doc \(Int(Date().timeIntervalSince1970))"
|
||||
title: "Image Section Doc \(Int(Date().timeIntervalSince1970))",
|
||||
documentType: "warranty"
|
||||
)
|
||||
|
||||
navigateToDocuments()
|
||||
|
||||
// Open the seeded document's detail
|
||||
// Pull to refresh until the seeded document is visible
|
||||
let docText = app.staticTexts[document.title]
|
||||
pullToRefreshUntilVisible(docText)
|
||||
docText.waitForExistenceOrFail(timeout: longTimeout)
|
||||
docText.forceTap()
|
||||
|
||||
@@ -152,17 +320,39 @@ final class DocumentIntegrationTests: AuthenticatedTestCase {
|
||||
// Seed a document via API — don't track since we'll delete through UI
|
||||
let residence = cleaner.seedResidence()
|
||||
let deleteTitle = "Delete Doc \(Int(Date().timeIntervalSince1970))"
|
||||
TestDataSeeder.createDocument(token: session.token, residenceId: residence.id, title: deleteTitle)
|
||||
TestDataSeeder.createDocument(token: session.token, residenceId: residence.id, title: deleteTitle, documentType: "warranty")
|
||||
|
||||
navigateToDocuments()
|
||||
|
||||
// Pull to refresh until the seeded document is visible
|
||||
let target = app.staticTexts[deleteTitle]
|
||||
pullToRefreshUntilVisible(target)
|
||||
target.waitForExistenceOrFail(timeout: longTimeout)
|
||||
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]
|
||||
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
deleteButton.forceTap()
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user