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:
Trey T
2026-06-05 16:26:50 -05:00
parent 09120e9d9d
commit c52ce4d497
44 changed files with 3824 additions and 3057 deletions
@@ -1,84 +0,0 @@
import XCTest
final class AccessibilityTests: BaseUITestCase {
override var relaunchBetweenTests: Bool { true }
func testA001_OnboardingPrimaryControlsAreReachable() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
app.buttons[UITestID.Onboarding.startFreshButton].waitUntilHittable(timeout: defaultTimeout)
app.buttons[UITestID.Onboarding.joinExistingButton].waitUntilHittable(timeout: defaultTimeout)
app.buttons[UITestID.Onboarding.loginButton].waitUntilHittable(timeout: defaultTimeout)
}
func testA002_LoginControlsRemainOperable() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
app.textFields[UITestID.Auth.usernameField].waitUntilHittable(timeout: defaultTimeout)
app.secureTextFields[UITestID.Auth.passwordField].waitUntilHittable(timeout: defaultTimeout)
app.buttons[UITestID.Auth.loginButton].waitUntilHittable(timeout: defaultTimeout)
login.tapPasswordVisibilityToggle()
login.assertPasswordFieldVisible()
}
func testA003_CoreControlsExposeIdentifiers() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
_ = login
XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists)
XCTAssertTrue(app.secureTextFields[UITestID.Auth.passwordField].exists || app.textFields[UITestID.Auth.passwordField].exists)
XCTAssertTrue(app.buttons[UITestID.Auth.signUpButton].exists)
XCTAssertTrue(app.buttons[UITestID.Auth.forgotPasswordButton].exists)
}
// MARK: - Additional Accessibility Coverage
func testA004_ValuePropsScreenControlsAreReachable() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad(timeout: defaultTimeout)
let continueButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsNextButton).firstMatch
continueButton.waitUntilHittable(timeout: defaultTimeout)
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
XCTAssertTrue(backButton.waitForExistence(timeout: defaultTimeout), "Back button should exist on value props screen")
}
func testA005_NameResidenceScreenControlsAreReachable() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad()
valueProps.tapContinue()
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad(timeout: defaultTimeout)
let nameField = app.textFields[UITestID.Onboarding.residenceNameField]
nameField.waitUntilHittable(timeout: defaultTimeout)
let continueButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceContinueButton).firstMatch
XCTAssertTrue(continueButton.waitForExistence(timeout: defaultTimeout), "Continue button should exist on name residence screen")
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
XCTAssertTrue(backButton.waitForExistence(timeout: defaultTimeout), "Back button should exist on name residence screen")
}
func testA006_CreateAccountScreenControlsAreReachable() {
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "A11Y Test")
createAccount.waitForLoad(timeout: defaultTimeout)
let createAccountTitle = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.createAccountTitle).firstMatch
XCTAssertTrue(createAccountTitle.exists, "Create account title should be accessible")
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
XCTAssertTrue(backButton.waitForExistence(timeout: defaultTimeout), "Back button should exist on create account screen")
}
}
@@ -1,19 +0,0 @@
import XCTest
final class AppLaunchTests: BaseUITestCase {
func testF001_ColdLaunchShowsOnboardingWelcome() {
RootScreen(app: app).waitForReady(timeout: defaultTimeout)
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad(timeout: defaultTimeout)
}
func testF002_ColdLaunchShowsPrimaryOnboardingActions() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
XCTAssertTrue(app.buttons[UITestID.Onboarding.startFreshButton].exists)
XCTAssertTrue(app.buttons[UITestID.Onboarding.joinExistingButton].exists)
XCTAssertTrue(app.buttons[UITestID.Onboarding.loginButton].exists)
}
}
@@ -1,104 +0,0 @@
import XCTest
final class AuthenticationTests: BaseUITestCase {
override var completeOnboarding: Bool { true }
override var relaunchBetweenTests: Bool { true }
func testF201_OnboardingLoginEntryShowsLoginScreen() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
}
func testF202_LoginScreenCanTogglePasswordVisibility() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.enterUsername("u")
login.enterPassword("p")
login.tapPasswordVisibilityToggle()
login.assertPasswordFieldVisible()
}
func testF203_RegisterSheetCanOpenAndDismiss() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.tapSignUp()
let register = RegisterScreenObject(app: app)
register.waitForLoad(timeout: navigationTimeout)
register.tapCancel()
login.waitForLoad(timeout: navigationTimeout)
}
func testF204_RegisterFormAcceptsInput() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.tapSignUp()
let register = RegisterScreenObject(app: app)
register.waitForLoad(timeout: navigationTimeout)
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist on register form")
}
func testF205_LoginButtonDisabledWhenCredentialsAreEmpty() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
XCTAssertFalse(loginButton.isEnabled, "Login button should be disabled when username/password are empty")
}
func testF206_ForgotPasswordButtonIsAccessible() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
let forgotButton = app.buttons[UITestID.Auth.forgotPasswordButton]
forgotButton.waitForExistenceOrFail(timeout: defaultTimeout)
XCTAssertTrue(forgotButton.isHittable, "Forgot password button should be hittable on login screen")
}
func testF207_LoginScreenShowsAllExpectedElements() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists, "Username field should exist")
XCTAssertTrue(
app.secureTextFields[UITestID.Auth.passwordField].exists || app.textFields[UITestID.Auth.passwordField].exists,
"Password field should exist"
)
XCTAssertTrue(app.buttons[UITestID.Auth.loginButton].exists, "Login button should exist")
XCTAssertTrue(app.buttons[UITestID.Auth.signUpButton].exists, "Sign up button should exist")
XCTAssertTrue(app.buttons[UITestID.Auth.forgotPasswordButton].exists, "Forgot password button should exist")
XCTAssertTrue(app.buttons[UITestID.Auth.passwordVisibilityToggle].exists, "Password visibility toggle should exist")
}
func testF208_RegisterFormShowsAllRequiredFields() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.tapSignUp()
let register = RegisterScreenObject(app: app)
register.waitForLoad(timeout: navigationTimeout)
XCTAssertTrue(app.textFields[UITestID.Auth.registerUsernameField].exists, "Register username field should exist")
XCTAssertTrue(app.textFields[UITestID.Auth.registerEmailField].exists, "Register email field should exist")
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerPasswordField].exists, "Register password field should exist")
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerConfirmPasswordField].exists, "Register confirm password field should exist")
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist")
XCTAssertTrue(app.buttons[UITestID.Auth.registerCancelButton].exists, "Register cancel button should exist")
}
func testF209_ForgotPasswordNavigatesToResetFlow() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.tapForgotPassword()
// Verify forgot password screen loaded by checking for its email field (accessibility ID, not label)
let emailField = app.textFields[UITestID.PasswordReset.emailField]
let sendCodeButton = app.buttons[UITestID.PasswordReset.sendCodeButton]
let loaded = emailField.waitForExistence(timeout: navigationTimeout)
|| sendCodeButton.waitForExistence(timeout: navigationTimeout)
XCTAssertTrue(loaded, "Forgot password screen should appear with email field or send code button")
}
}
@@ -1,240 +0,0 @@
import XCTest
/// Integration tests for contractor CRUD against the real local backend.
///
/// Test Plan IDs: CON-002, CON-005, CON-006
/// Data is seeded via API and cleaned up in tearDown.
final class ContractorIntegrationTests: AuthenticatedUITestCase {
override var needsAPISession: Bool { true }
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
// MARK: - CON-002: Create Contractor
func testCON002_CreateContractorMinimalFields() {
navigateToContractors()
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton].firstMatch
let emptyState = app.otherElements[AccessibilityIdentifiers.Contractor.emptyStateView]
let contractorList = app.otherElements[AccessibilityIdentifiers.Contractor.contractorsList]
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|| emptyState.waitForExistence(timeout: 3)
|| contractorList.waitForExistence(timeout: 3)
XCTAssertTrue(loaded, "Contractors 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 nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
let uniqueName = "IntTest Contractor \(Int(Date().timeIntervalSince1970))"
nameField.forceTap()
nameField.typeText(uniqueName)
// Dismiss keyboard before tapping save (toolbar button may not respond with keyboard up)
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
// Save button is in the toolbar (top of sheet)
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
saveButton.forceTap()
// Wait for the sheet to dismiss (save triggers async API call + dismiss)
let nameFieldGone = nameField.waitForNonExistence(timeout: loginTimeout)
if !nameFieldGone {
// If still showing the form, try tapping save again
if saveButton.exists {
saveButton.forceTap()
_ = nameField.waitForNonExistence(timeout: loginTimeout)
}
}
// Pull to refresh to pick up the newly created contractor
pullToRefresh()
// Wait for the contractor list to show the new entry
let newContractor = app.staticTexts[uniqueName]
if !newContractor.waitForExistence(timeout: defaultTimeout) {
// Pull to refresh again in case the first one was too early
pullToRefresh()
}
XCTAssertTrue(
newContractor.waitForExistence(timeout: defaultTimeout),
"Newly created contractor should appear in list"
)
}
// MARK: - CON-005: Edit Contractor
func testCON005_EditContractor() {
// Seed a contractor via API
let contractor = cleaner.seedContractor(name: "Edit Target Contractor \(Int(Date().timeIntervalSince1970))")
navigateToContractors()
// Pull to refresh until the seeded contractor is visible (increase retries for API propagation)
let card = app.staticTexts[contractor.name]
pullToRefreshUntilVisible(card, maxRetries: 5)
card.waitForExistenceOrFail(timeout: loginTimeout)
card.forceTap()
// Tap the ellipsis menu to reveal edit/delete options
let menuButton = app.buttons[AccessibilityIdentifiers.Contractor.menuButton]
if menuButton.waitForExistence(timeout: defaultTimeout) {
menuButton.forceTap()
} else {
// Fallback: last nav bar button
let navBarMenu = app.navigationBars.buttons.element(boundBy: app.navigationBars.buttons.count - 1)
if navBarMenu.exists { navBarMenu.forceTap() }
}
// Tap edit
let editButton = app.buttons[AccessibilityIdentifiers.Contractor.editButton]
if !editButton.waitForExistence(timeout: defaultTimeout) {
// Fallback: look for any Edit button
let anyEdit = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Edit'")
).firstMatch
anyEdit.waitForExistenceOrFail(timeout: 5)
anyEdit.forceTap()
} else {
editButton.forceTap()
}
// Update name select all existing text and type replacement
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
let updatedName = "Updated Contractor \(Int(Date().timeIntervalSince1970))"
nameField.clearAndEnterText(updatedName, app: app)
// Dismiss keyboard before tapping save
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
saveButton.forceTap()
// After save, the form dismisses back to detail view. Navigate back to list.
_ = nameField.waitForNonExistence(timeout: loginTimeout)
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.waitForExistence(timeout: defaultTimeout) {
backButton.tap()
}
// Pull to refresh to pick up the edit
let updatedText = app.staticTexts[updatedName]
pullToRefreshUntilVisible(updatedText, maxRetries: 5)
// The DataManager cache may delay the list update.
// The edit was verified at the field level (clearAndEnterText succeeded),
// so accept if the original name is still showing in the list.
if !updatedText.exists {
let originalStillShowing = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Edit Target'")
).firstMatch.exists
if originalStillShowing { return }
}
XCTAssertTrue(updatedText.exists, "Updated contractor name should appear after edit")
}
// MARK: - CON-006: Delete Contractor
func testCON006_DeleteContractor() {
// Seed a contractor via API don't track with cleaner since we'll delete via UI
let deleteName = "Delete Contractor \(Int(Date().timeIntervalSince1970))"
TestDataSeeder.createContractor(token: session.token, name: deleteName)
navigateToContractors()
// Pull to refresh until the seeded contractor is visible (increase retries for API propagation)
let target = app.staticTexts[deleteName]
pullToRefreshUntilVisible(target, maxRetries: 5)
target.waitForExistenceOrFail(timeout: loginTimeout)
// Open the contractor's detail view
target.forceTap()
// Wait for detail view to load
let detailView = app.otherElements[AccessibilityIdentifiers.Contractor.detailView]
_ = detailView.waitForExistence(timeout: defaultTimeout)
// Tap the ellipsis menu button
// SwiftUI Menu can be a button, popUpButton, or image
let menuButton = app.buttons[AccessibilityIdentifiers.Contractor.menuButton]
let menuImage = app.images[AccessibilityIdentifiers.Contractor.menuButton]
let menuPopUp = app.popUpButtons.firstMatch
if menuButton.waitForExistence(timeout: 5) {
menuButton.forceTap()
} else if menuImage.waitForExistence(timeout: 3) {
menuImage.forceTap()
} else if menuPopUp.waitForExistence(timeout: 3) {
menuPopUp.forceTap()
} else {
// Debug: dump nav bar buttons to understand what's available
let navButtons = app.navigationBars.buttons.allElementsBoundByIndex
let navButtonInfo = navButtons.prefix(10).map { "[\($0.identifier)|\($0.label)]" }
let allButtons = app.buttons.allElementsBoundByIndex
let buttonInfo = allButtons.prefix(15).map { "[\($0.identifier)|\($0.label)]" }
XCTFail("Could not find menu button. Nav bar buttons: \(navButtonInfo). All buttons: \(buttonInfo)")
return
}
// Find and tap "Delete" in the menu popup
let deleteButton = app.buttons[AccessibilityIdentifiers.Contractor.deleteButton]
if deleteButton.waitForExistence(timeout: defaultTimeout) {
deleteButton.forceTap()
} else {
let anyDelete = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Delete'")
).firstMatch
anyDelete.waitForExistenceOrFail(timeout: 5)
anyDelete.forceTap()
}
// Confirm the delete in the alert
let alert = app.alerts.firstMatch
alert.waitForExistenceOrFail(timeout: defaultTimeout)
let deleteLabel = alert.buttons["Delete"]
if deleteLabel.waitForExistence(timeout: 3) {
deleteLabel.tap()
} else {
// Fallback: tap any button containing "Delete"
let anyDeleteBtn = alert.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Delete'")
).firstMatch
if anyDeleteBtn.exists {
anyDeleteBtn.tap()
} else {
// Last resort: tap the last button (destructive buttons are last)
let count = alert.buttons.count
alert.buttons.element(boundBy: count > 0 ? count - 1 : 0).tap()
}
}
// Wait for the detail view to dismiss and return to list
_ = detailView.waitForNonExistence(timeout: loginTimeout)
// Pull to refresh in case the list didn't auto-update
pullToRefresh()
// Verify the contractor is no longer visible
let deletedContractor = app.staticTexts[deleteName]
XCTAssertTrue(
deletedContractor.waitForNonExistence(timeout: loginTimeout),
"Deleted contractor should no longer appear"
)
}
}
@@ -29,7 +29,8 @@ final class DataLayerTests: AuthenticatedUITestCase {
UITestHelpers.ensureOnLoginScreen(app: app)
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.enterUsername("admin")
// Kratos uses the EMAIL as the login identifier (no username trait).
login.enterUsername("admin@honeydue.com")
login.enterPassword("Test1234")
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].waitForExistenceOrFail(timeout: defaultTimeout).forceTap()
@@ -1,440 +0,0 @@
import XCTest
/// Integration tests for document CRUD against the real local backend.
///
/// Test Plan IDs: DOC-002, DOC-004, DOC-005
/// Data is seeded via API and cleaned up in tearDown.
final class DocumentIntegrationTests: AuthenticatedUITestCase {
override var needsAPISession: Bool { true }
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
// MARK: - Helpers
/// Navigate to the Documents tab and wait for it to load.
///
/// The Documents/Warranties view defaults to the Warranties sub-tab and
/// shows a horizontal ScrollView for filter chips ("Active Only").
/// Because `pullToRefresh()` uses `app.scrollViews.firstMatch`, it can
/// accidentally target that horizontal chip ScrollView instead of the
/// vertical content ScrollView, causing the refresh gesture to silently
/// fail. Use `pullToRefreshDocuments()` instead of the base-class
/// `pullToRefresh()` on this screen.
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() {
// Drag from upper-middle of the screen to lower-middle.
// The vertical content area sits roughly between y 0.25 and y 0.90
// of the screen (below the segmented control + search bar + chips).
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: - DOC-002: Create Document
func testDOC002_CreateDocumentWithRequiredFields() {
// Seed a residence so the picker has an option to select
let residence = cleaner.seedResidence(name: "DocTest Residence \(Int(Date().timeIntervalSince1970))")
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 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] %@", 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()
}
}
// 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.
// After successful create, the form calls DataManager.addDocument() which
// updates the DocumentViewModel's observed documents list. Additionally do
// a pull-to-refresh (targeting the correct vertical ScrollView) in case the
// cache needs a full reload.
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
func testDOC004_EditDocument() {
// 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))", documentType: "warranty")
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.
// Wait for form to dismiss, then navigate back to the list.
_ = 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
// 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 {
// Seed a residence and a document via API
let residence = cleaner.seedResidence()
let document = cleaner.seedDocument(
residenceId: residence.id,
title: "Image Section Doc \(Int(Date().timeIntervalSince1970))",
documentType: "warranty"
)
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
func testDOC005_DeleteDocument() {
// 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, documentType: "warranty")
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"
)
}
}
@@ -7,6 +7,10 @@ final class FeatureCoverageTests: AuthenticatedUITestCase {
override var needsAPISession: Bool { true }
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
// Relaunch per test: the residence-detail kanban can show a stale empty list
// for an API-seeded task when reusing a session (the known empty-cache window),
// so a fresh launch per test keeps task-completion tests (07/08) deterministic.
override var relaunchBetweenTests: Bool { true }
// MARK: - Helpers
@@ -53,6 +57,14 @@ final class FeatureCoverageTests: AuthenticatedUITestCase {
// Seed a residence via API so we always have a known target
let residenceName = "FeatureCoverage Home \(Int(Date().timeIntervalSince1970))"
let seeded = cleaner.seedResidence(name: residenceName)
// Tests 07/08 expect a pre-existing "Seed Task" in the residence detail.
// Fresh Kratos accounts have no data, so seed the task explicitly here.
// The detail screen defaults to the "Overdue" column, so give the task a
// past due date to guarantee it renders in the default visible column.
let dueFormatter = ISO8601DateFormatter()
dueFormatter.formatOptions = [.withFullDate]
let pastDue = dueFormatter.string(from: Calendar.current.date(byAdding: .day, value: -3, to: Date())!)
_ = cleaner.seedTask(residenceId: seeded.id, title: "Seed Task", fields: ["due_date": pastDue])
navigateToResidences()
@@ -69,6 +81,14 @@ final class FeatureCoverageTests: AuthenticatedUITestCase {
// Wait for detail to load
let detailContent = app.staticTexts[seeded.name]
_ = detailContent.waitForExistence(timeout: defaultTimeout)
// The task was seeded via API after the detail view's cache was primed, so
// its kanban can show an empty (stale) list. Pull-to-refresh until the
// seeded "Seed Task" surfaces, defeating the empty-cache window.
let seedTask = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Seed Task'")
).firstMatch
pullToRefreshUntilVisible(seedTask, maxRetries: 4)
}
// MARK: - Profile Edit
@@ -136,9 +136,12 @@ final class MultiUserSharingTests: XCTestCase {
// Step 8: Verify the residence has 2 users
if let users = TestAccountAPIClient.listResidenceUsers(token: userA.token, residenceId: residenceId) {
XCTAssertEqual(users.count, 2, "Shared residence should have 2 users")
// The Go API provisions a Kratos-backed user with username == email,
// not the bare username passed to createKratosIdentity. Compare against
// the API-provisioned identity (userX.user.username), not the local label.
let usernames = users.map { $0.username }
XCTAssertTrue(usernames.contains(userA.username), "User list should include User A")
XCTAssertTrue(usernames.contains(userB.username), "User list should include User B")
XCTAssertTrue(usernames.contains(userA.user.username), "User list should include User A")
XCTAssertTrue(usernames.contains(userB.user.username), "User list should include User B")
}
// Cleanup
@@ -1,426 +0,0 @@
import XCTest
/// XCUITests for multi-user residence sharing.
///
/// Pattern: User A's data is seeded via API before app launch.
/// The app launches logged in as User B (via AuthenticatedUITestCase with UI-driven login).
/// User B joins User A's residence through the UI and verifies shared data.
///
/// ALL assertions check UI elements only. If the UI doesn't show the expected
/// data, that indicates a real app bug and the test should fail.
final class MultiUserSharingUITests: AuthenticatedUITestCase {
/// User A's session (API-only, set up before app launch)
private var userASession: TestSession!
/// User B's session (fresh account, logged in via UI)
private var userBSession: TestSession!
/// The shared residence ID
private var sharedResidenceId: Int!
/// The share code User B will enter in the UI
private var shareCode: String!
/// The residence name (to verify in UI)
private var sharedResidenceName: String!
/// Titles of tasks/documents seeded by User A (to verify in UI)
private var userATaskTitle: String!
private var userADocTitle: String!
/// Stored credentials for User B, set before super.setUpWithError() calls loginToMainApp()
private var _userBUsername: String = ""
private var _userBPassword: String = ""
/// Dynamic credentials returns User B's freshly created account
override var testCredentials: (username: String, password: String) {
(_userBUsername, _userBPassword)
}
override func setUpWithError() throws {
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Local backend not reachable")
}
// Create User A via API
let runId = UUID().uuidString.prefix(6)
guard let a = TestAccountAPIClient.createVerifiedAccount(
username: "owner_\(runId)",
email: "owner_\(runId)@test.com",
password: "TestPass123!"
) else {
XCTFail("Could not create User A (owner)"); return
}
userASession = a
// User A creates a residence
sharedResidenceName = "Shared House \(runId)"
guard let residence = TestAccountAPIClient.createResidence(
token: userASession.token,
name: sharedResidenceName
) else {
XCTFail("Could not create residence for User A"); return
}
sharedResidenceId = residence.id
// User A generates a share code
guard let code = TestAccountAPIClient.generateShareCode(
token: userASession.token,
residenceId: sharedResidenceId
) else {
XCTFail("Could not generate share code"); return
}
shareCode = code.code
// User A seeds data on the residence
userATaskTitle = "Fix Roof \(runId)"
_ = TestAccountAPIClient.createTask(
token: userASession.token,
residenceId: sharedResidenceId,
title: userATaskTitle
)
userADocTitle = "Home Warranty \(runId)"
_ = TestAccountAPIClient.createDocument(
token: userASession.token,
residenceId: sharedResidenceId,
title: userADocTitle,
documentType: "warranty"
)
// Create User B via API (fresh account)
guard let b = TestAccountManager.createVerifiedAccount() else {
XCTFail("Could not create User B (fresh account)"); return
}
userBSession = b
// Set User B's credentials BEFORE super.setUpWithError() calls loginToMainApp()
_userBUsername = b.username
_userBPassword = b.password
// Now launch the app and login as User B via base class
try super.setUpWithError()
}
override func tearDownWithError() throws {
// Clean up User A's data
if let id = sharedResidenceId, let token = userASession?.token {
_ = TestAccountAPIClient.deleteResidence(token: token, id: id)
}
try super.tearDownWithError()
}
// MARK: - Test 01: Join Residence via UI Share Code
func test01_joinResidenceWithShareCode() {
navigateToResidences()
// Tap the join button (person.badge.plus icon in toolbar)
let joinButton = findJoinButton()
XCTAssertTrue(joinButton.waitForExistence(timeout: defaultTimeout), "Join button should exist")
joinButton.tap()
// Verify JoinResidenceView appeared
let codeField = app.textFields["JoinResidence.ShareCodeField"]
XCTAssertTrue(codeField.waitForExistence(timeout: defaultTimeout),
"Share code field should appear")
// Type the share code
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText(shareCode)
// Tap Join
let joinAction = app.buttons["JoinResidence.JoinButton"]
XCTAssertTrue(joinAction.waitForExistence(timeout: defaultTimeout), "Join button should exist")
XCTAssertTrue(joinAction.isEnabled, "Join button should be enabled with 6-char code")
joinAction.tap()
// Wait for join to complete the sheet should dismiss
// Verify the join screen dismissed (code field should be gone)
let codeFieldGone = codeField.waitForNonExistence(timeout: 10)
XCTAssertTrue(codeFieldGone || !codeField.exists,
"Join sheet should dismiss after successful join")
// Verify the shared residence name appears in the Residences list
let residenceText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", sharedResidenceName)
).firstMatch
pullToRefreshUntilVisible(residenceText, maxRetries: 3)
XCTAssertTrue(residenceText.exists,
"Shared residence '\(sharedResidenceName!)' should appear in Residences list after joining")
}
// MARK: - Test 02: Joined Residence Shows Data in UI
func test02_joinedResidenceShowsSharedDocuments() {
// Join via UI
joinResidenceViaUI()
// Verify residence appears in Residences tab
let residenceText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", sharedResidenceName)
).firstMatch
pullToRefreshUntilVisible(residenceText, maxRetries: 3)
XCTAssertTrue(residenceText.exists,
"Shared residence '\(sharedResidenceName!)' should appear in Residences list")
// Navigate to Documents tab and verify User A's document title appears
navigateToDocuments()
let docText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", userADocTitle)
).firstMatch
pullToRefreshUntilVisible(docText, maxRetries: 3)
XCTAssertTrue(docText.exists,
"User A's document '\(userADocTitle!)' should be visible in Documents tab after joining the shared residence")
}
// MARK: - Test 03: Shared Tasks Visible in UI
/// Known issue: After joining a shared residence, the Tasks tab doesn't show
/// the shared tasks. The AllTasksView's residenceViewModel uses cached (empty)
/// data, which disables the refresh button and prevents task loading.
/// Fix: AllTasksView.onAppear should detect residence list changes or use
/// DataManager's already-refreshed cache.
func test03_sharedTasksVisibleInTasksTab() {
// Join via UI this lands on Residences tab which triggers forceRefresh
joinResidenceViaUI()
// Verify the residence appeared (confirms join + refresh worked)
let sharedRes = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", sharedResidenceName)
).firstMatch
XCTAssertTrue(sharedRes.waitForExistence(timeout: defaultTimeout),
"Shared residence should be visible before navigating to Tasks")
// Navigate to Tasks tab
navigateToTasks()
// Tap the refresh button (arrow.clockwise) to force-reload tasks
let refreshButton = app.navigationBars.buttons.containing(
NSPredicate(format: "label CONTAINS 'arrow.clockwise'")
).firstMatch
for _ in 0..<5 {
if refreshButton.waitForExistence(timeout: 3) && refreshButton.isEnabled {
refreshButton.tap()
// Wait for task data to load
_ = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", userATaskTitle)
).firstMatch.waitForExistence(timeout: defaultTimeout)
break
}
// If disabled, wait for residence data to propagate
_ = refreshButton.waitForExistence(timeout: 3)
}
// Search for User A's task title it may be in any kanban column
let taskText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", userATaskTitle)
).firstMatch
// Kanban is a horizontal scroll swipe left through columns to find the task
for _ in 0..<5 {
if taskText.exists { break }
app.swipeLeft()
}
XCTAssertTrue(taskText.waitForExistence(timeout: defaultTimeout),
"User A's task '\(userATaskTitle!)' should be visible in Tasks tab after joining the shared residence")
}
// MARK: - Test 04: Shared Residence Shows in Documents Tab
func test04_sharedResidenceShowsInDocumentsTab() {
joinResidenceViaUI()
navigateToDocuments()
// Look for User A's document
let docText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Home Warranty'")
).firstMatch
pullToRefreshUntilVisible(docText, maxRetries: 3)
// Document may or may not show depending on filtering verify the tab loaded
let documentsTab = app.tabBars.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Doc'")
).firstMatch
XCTAssertTrue(documentsTab.isSelected, "Documents tab should be selected")
}
// MARK: - Test 05: Cross-User Document Visibility in UI
func test05_crossUserDocumentVisibleInUI() {
// Join via UI
joinResidenceViaUI()
// Navigate to Documents tab
navigateToDocuments()
// Verify User A's seeded document appears
let docText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", userADocTitle)
).firstMatch
pullToRefreshUntilVisible(docText, maxRetries: 3)
XCTAssertTrue(docText.exists,
"User A's document '\(userADocTitle!)' should be visible to User B in the Documents tab")
}
// MARK: - Test 06: Join Button Disabled With Short Code
func test06_joinResidenceButtonDisabledWithShortCode() {
navigateToResidences()
let joinButton = findJoinButton()
guard joinButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Join button should exist"); return
}
joinButton.tap()
let codeField = app.textFields["JoinResidence.ShareCodeField"]
guard codeField.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Share code field should appear"); return
}
// Type only 3 characters
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("ABC")
let joinAction = app.buttons["JoinResidence.JoinButton"]
XCTAssertTrue(joinAction.exists, "Join button should exist")
XCTAssertFalse(joinAction.isEnabled, "Join button should be disabled with < 6 chars")
// Dismiss
let dismissButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Cancel' OR label CONTAINS[c] 'xmark'")
).firstMatch
if dismissButton.exists { dismissButton.tap() }
}
// MARK: - Test 07: Invalid Code Shows Error
func test07_joinWithInvalidCodeShowsError() {
navigateToResidences()
let joinButton = findJoinButton()
guard joinButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Join button should exist"); return
}
joinButton.tap()
let codeField = app.textFields["JoinResidence.ShareCodeField"]
guard codeField.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Share code field should appear"); return
}
// Type an invalid 6-char code
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("ZZZZZZ")
let joinAction = app.buttons["JoinResidence.JoinButton"]
joinAction.tap()
// Wait for API response - either error text appears or we stay on join screen
let errorText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'error' OR label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'not found' OR label CONTAINS[c] 'expired'")
).firstMatch
_ = errorText.waitForExistence(timeout: defaultTimeout)
// Should show an error message (code field should still be visible = still on join screen)
let stillOnJoinScreen = codeField.exists
XCTAssertTrue(errorText.exists || stillOnJoinScreen,
"Should show error or remain on join screen with invalid code")
// Dismiss
let dismissButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Cancel' OR label CONTAINS[c] 'xmark'")
).firstMatch
if dismissButton.exists { dismissButton.tap() }
}
// MARK: - Test 08: Residence Detail Shows After Join
func test08_residenceDetailAccessibleAfterJoin() {
// Join via UI
joinResidenceViaUI()
// Find and tap the shared residence in the list
let residenceText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", sharedResidenceName)
).firstMatch
pullToRefreshUntilVisible(residenceText, maxRetries: 3)
XCTAssertTrue(residenceText.exists,
"Shared residence '\(sharedResidenceName!)' should appear in Residences list")
residenceText.tap()
// Verify the residence detail view loads and shows the residence name
let detailTitle = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", sharedResidenceName)
).firstMatch
XCTAssertTrue(detailTitle.waitForExistence(timeout: defaultTimeout),
"Residence detail should display the residence name '\(sharedResidenceName!)'")
// Look for indicators of multiple users (e.g. "2 users", "Members", user list)
let multiUserIndicator = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] '2 user' OR label CONTAINS[c] '2 member' OR label CONTAINS[c] 'Members' OR label CONTAINS[c] 'Manage Users' OR label CONTAINS[c] 'Users'")
).firstMatch
// If a user count or members section is visible, verify it
if multiUserIndicator.waitForExistence(timeout: 5) {
XCTAssertTrue(multiUserIndicator.exists,
"Residence detail should show information about multiple users")
}
// If no explicit user indicator is visible (non-owner may not see Manage Users),
// the test still passes because we verified the residence detail loaded successfully.
}
// MARK: - Helpers
/// Find the join residence button in the toolbar
private func findJoinButton() -> XCUIElement {
// Look for the person.badge.plus button in the navigation bar
let navButtons = app.navigationBars.buttons
for i in 0..<navButtons.count {
let button = navButtons.element(boundBy: i)
if button.label.contains("person.badge.plus") || button.label.contains("Join") {
return button
}
}
// Fallback: any button with person.badge.plus
return app.buttons.containing(
NSPredicate(format: "label CONTAINS 'person.badge.plus'")
).firstMatch
}
/// Join the shared residence via the UI (type share code, tap join).
/// After joining, verifies the join sheet dismissed and returns to the Residences list.
private func joinResidenceViaUI() {
navigateToResidences()
let joinButton = findJoinButton()
guard joinButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Join button not found"); return
}
joinButton.tap()
let codeField = app.textFields["JoinResidence.ShareCodeField"]
guard codeField.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Share code field not found"); return
}
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText(shareCode)
let joinAction = app.buttons["JoinResidence.JoinButton"]
guard joinAction.waitForExistence(timeout: defaultTimeout), joinAction.isEnabled else {
XCTFail("Join button not enabled"); return
}
joinAction.tap()
// After join, wait for the sheet to dismiss
_ = codeField.waitForNonExistence(timeout: loginTimeout)
// List should refresh
pullToRefresh()
}
}
@@ -1,273 +0,0 @@
import XCTest
final class OnboardingTests: BaseUITestCase {
override var relaunchBetweenTests: Bool { true }
func testF101_StartFreshFlowReachesCreateAccount() {
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Blueprint House")
createAccount.waitForLoad(timeout: defaultTimeout)
}
func testF102_JoinExistingFlowGoesToCreateAccount() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapJoinExisting()
let createAccount = OnboardingCreateAccountScreen(app: app)
createAccount.waitForLoad(timeout: defaultTimeout)
}
func testF103_BackNavigationFromNameResidenceReturnsToValueProps() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad()
valueProps.tapContinue()
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad()
nameResidence.tapBack()
XCTAssertTrue(app.otherElements[UITestID.Root.onboarding].waitForExistence(timeout: defaultTimeout))
}
func testF104_SkipOnValuePropsMovesToNameResidence() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad()
let skipButton = app.buttons[UITestID.Onboarding.skipButton]
skipButton.waitForExistenceOrFail(timeout: defaultTimeout)
skipButton.forceTap()
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad(timeout: defaultTimeout)
}
// MARK: - Additional Onboarding Coverage
func testF105_JoinExistingFlowSkipsValuePropsAndNameResidence() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapJoinExisting()
let createAccount = OnboardingCreateAccountScreen(app: app)
createAccount.waitForLoad(timeout: defaultTimeout)
// Verify value props and name residence screens were NOT shown
let valuePropsTitle = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsContainer).firstMatch
XCTAssertFalse(valuePropsTitle.exists, "Value props should be skipped for Join Existing flow")
let nameResidenceTitle = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceTitle).firstMatch
XCTAssertFalse(nameResidenceTitle.exists, "Name residence should be skipped for Join Existing flow")
}
func testF106_NameResidenceFieldAcceptsInput() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad()
valueProps.tapContinue()
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad()
let nameField = app.textFields[UITestID.Onboarding.residenceNameField]
nameField.waitUntilHittable(timeout: defaultTimeout)
nameField.focusAndType("My Test Home", app: app)
XCTAssertEqual(nameField.value as? String, "My Test Home", "Residence name field should accept and display typed text")
}
func testF107_ProgressIndicatorVisibleDuringOnboarding() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad()
let progress = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.progressIndicator).firstMatch
XCTAssertTrue(progress.waitForExistence(timeout: defaultTimeout), "Progress indicator should be visible during onboarding flow")
}
func testF108_BackFromCreateAccountNavigatesToPreviousStep() {
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Back Test")
createAccount.waitForLoad(timeout: defaultTimeout)
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
backButton.waitForExistenceOrFail(timeout: defaultTimeout)
backButton.forceTap()
// Should return to name residence step
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad(timeout: defaultTimeout)
}
// MARK: - ONB-005: Residence Bootstrap
/// ONB-005: Start Fresh creates a residence automatically after email verification.
/// Drives the full Start Fresh flow welcome value props name residence
/// create account verify email then confirms the app lands on main tabs,
/// which indicates the residence was bootstrapped during onboarding.
func testF110_startFreshCreatesResidenceAfterVerification() throws {
try? XCTSkipIf(
!TestAccountAPIClient.isBackendReachable(),
"Local backend is not reachable — skipping ONB-005"
)
// Generate unique credentials so we don't collide with other test runs
let creds = TestAccountManager.uniqueCredentials(prefix: "onb005")
let uniqueResidenceName = "ONB005 Home \(Int(Date().timeIntervalSince1970))"
// Step 1: Navigate Start Fresh flow to the Create Account screen
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: uniqueResidenceName)
createAccount.waitForLoad(timeout: defaultTimeout)
// Step 2: Expand the email sign-up form and fill it in
createAccount.expandEmailSignup()
// Use the Onboarding-specific field identifiers for the create account form
let onbUsernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
let onbEmailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
let onbPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.passwordField]
let onbConfirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
onbUsernameField.waitForExistenceOrFail(timeout: defaultTimeout)
onbUsernameField.focusAndType(creds.username, app: app)
onbEmailField.waitForExistenceOrFail(timeout: defaultTimeout)
onbEmailField.focusAndType(creds.email, app: app)
onbPasswordField.waitForExistenceOrFail(timeout: defaultTimeout)
onbPasswordField.focusAndType(creds.password, app: app)
onbConfirmPasswordField.waitForExistenceOrFail(timeout: defaultTimeout)
onbConfirmPasswordField.focusAndType(creds.password, app: app)
// Step 3: Submit the create account form
let createAccountButton = app.descendants(matching: .any)
.matching(identifier: UITestID.Onboarding.createAccountButton).firstMatch
createAccountButton.waitForExistenceOrFail(timeout: defaultTimeout)
createAccountButton.forceTap()
// Step 4: Verify email with the debug code
let verificationScreen = VerificationScreen(app: app)
// If the create account button was disabled (password fields didn't fill),
// we won't reach verification. Check before asserting.
let verificationLoaded = verificationScreen.codeField.waitForExistence(timeout: loginTimeout)
guard verificationLoaded else {
// Check if the create account button is still visible (form submission failed)
if createAccountButton.exists {
throw XCTSkip("Create account form submission did not proceed to verification — password fields may not have received input")
}
XCTFail("Expected verification screen to load")
return
}
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
verificationScreen.submitCode()
// Step 5: After verification, the app should transition to main tabs.
// Landing on main tabs proves the onboarding completed and the residence
// was bootstrapped automatically no manual residence creation was required.
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
let tabBar = app.tabBars.firstMatch
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|| tabBar.waitForExistence(timeout: 5)
XCTAssertTrue(
reachedMain,
"App should reach main tabs after Start Fresh onboarding + email verification, " +
"confirming the residence '\(uniqueResidenceName)' was created automatically"
)
}
// MARK: - ONB-008: Completion Persistence
/// ONB-008: Completing onboarding persists the completion flag so the next
/// launch bypasses onboarding entirely and goes directly to login or main tabs.
func testF111_completedOnboardingBypassedOnRelaunch() {
try? XCTSkipIf(
!TestAccountAPIClient.isBackendReachable(),
"Local backend is not reachable — skipping ONB-008"
)
// Step 1: Complete onboarding via the Join Existing path (quickest path to main tabs).
// Navigate to the create account screen which marks the onboarding intent as started.
// Then use a pre-seeded account so we can reach main tabs without creating a new account.
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapAlreadyHaveAccount()
// Log in with the seeded account to complete onboarding and reach main tabs
let login = LoginScreenObject(app: app)
// The login sheet may take time to appear after onboarding transition
let loginFieldAppeared = app.textFields[UITestID.Auth.usernameField].waitForExistence(timeout: loginTimeout)
guard loginFieldAppeared else {
// If already on main tabs (persisted session), skip login
if app.tabBars.firstMatch.exists { /* continue to Step 2 */ }
else { XCTFail("Login screen did not appear after tapping Already Have Account"); return }
return
}
login.enterUsername("admin")
login.enterPassword("Test1234")
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
// Wait for main tabs this confirms onboarding is considered complete
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
let tabBar = app.tabBars.firstMatch
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|| tabBar.waitForExistence(timeout: 5)
XCTAssertTrue(reachedMain, "Should reach main tabs after first login to establish completed-onboarding state")
// Step 2: Terminate the app
app.terminate()
// Step 3: Relaunch WITHOUT --reset-state so the onboarding-completed flag is preserved.
// This simulates a real app restart where the user should NOT see onboarding again.
app.launchArguments = [
"--ui-testing",
"--disable-animations"
// NOTE: intentionally omitting --reset-state
]
app.launch()
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: defaultTimeout)
// Step 4: The app should NOT show the onboarding welcome screen.
// It should land on the login screen (token expired/missing) or main tabs
// (if the auth token persisted). Either outcome is valid what matters is
// that the onboarding root is NOT shown.
let onboardingWelcomeTitle = app.descendants(matching: .any)
.matching(identifier: UITestID.Onboarding.welcomeTitle).firstMatch
let startFreshButton = app.descendants(matching: .any)
.matching(identifier: UITestID.Onboarding.startFreshButton).firstMatch
// Wait for the app to settle on its landing screen
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
_ = loginField.waitForExistence(timeout: defaultTimeout)
|| mainTabs.waitForExistence(timeout: 3)
|| tabBar.waitForExistence(timeout: 3)
let isShowingOnboarding = onboardingWelcomeTitle.exists || startFreshButton.exists
XCTAssertFalse(
isShowingOnboarding,
"App should NOT show the onboarding welcome screen after onboarding was completed on a previous launch"
)
// Additionally verify the app landed on a valid post-onboarding screen
let isOnLogin = loginField.waitForExistence(timeout: defaultTimeout)
let isOnMain = mainTabs.exists || tabBar.exists
XCTAssertTrue(
isOnLogin || isOnMain,
"After relaunch without reset, app should show login or main tabs — not onboarding"
)
}
}
@@ -1,234 +0,0 @@
import XCTest
/// Tests for the password reset flow against the local backend (DEBUG=true, code=123456).
///
/// Test Plan IDs: AUTH-015, AUTH-016, AUTH-017
final class PasswordResetTests: BaseUITestCase {
override var relaunchBetweenTests: Bool { true }
private var testSession: TestSession?
private var cleaner: TestDataCleaner?
override func setUpWithError() throws {
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
}
guard let session = TestAccountManager.createVerifiedAccount() else {
throw XCTSkip("Could not create verified test account")
}
testSession = session
cleaner = TestDataCleaner(token: session.token)
// Force clean app launch password reset flow leaves complex screen state
app.terminate()
try super.setUpWithError()
}
override func tearDownWithError() throws {
cleaner?.cleanAll()
try super.tearDownWithError()
}
// MARK: - AUTH-015: Verify reset code reaches new password screen
func testAUTH015_VerifyResetCodeSuccessPath() throws {
let session = try XCTUnwrap(testSession)
// Navigate to forgot password
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.tapForgotPassword()
// Enter email and send code
let forgotScreen = ForgotPasswordScreen(app: app)
forgotScreen.waitForLoad()
forgotScreen.enterEmail(session.user.email)
forgotScreen.tapSendCode()
// Enter the debug verification code
let verifyScreen = VerifyResetCodeScreen(app: app)
verifyScreen.waitForLoad()
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
verifyScreen.tapVerify()
// Should reach the new password screen
let resetScreen = ResetPasswordScreen(app: app)
try resetScreen.waitForLoad(timeout: loginTimeout)
}
// MARK: - AUTH-016: Full reset password cycle + login with new password
func testAUTH016_ResetPasswordSuccess() throws {
let session = try XCTUnwrap(testSession)
let newPassword = "NewPass9876!"
// Navigate to forgot password
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.tapForgotPassword()
// Complete the full reset flow via UI
try TestFlows.completeForgotPasswordFlow(
app: app,
email: session.user.email,
newPassword: newPassword
)
// After reset, the app auto-logs in with the new password.
// If auto-login succeeds app goes directly to main tabs (sheet dismissed).
// If auto-login fails success message + "Return to Login" button appear.
let tabBar = app.tabBars.firstMatch
let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton]
let deadline = Date().addingTimeInterval(loginTimeout)
var reachedPostReset = false
while Date() < deadline {
if tabBar.exists {
// Auto-login succeeded password reset worked!
reachedPostReset = true
break
}
if returnButton.exists {
// Auto-login failed manual login needed
reachedPostReset = true
returnButton.forceTap()
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
XCTAssertTrue(reachedPostReset, "Expected main tabs (auto-login) or return button (manual login) after password reset")
if tabBar.exists {
// Already logged in via auto-login test passed
return
}
// Manual login path: return button was tapped, now on login screen
let loginScreen = LoginScreenObject(app: app)
loginScreen.waitForLoad(timeout: loginTimeout)
loginScreen.enterUsername(session.username)
loginScreen.enterPassword(newPassword)
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitUntilHittable(timeout: 10).tap()
XCTAssertTrue(tabBar.waitForExistence(timeout: loginTimeout), "Should login successfully with new password")
}
// MARK: - AUTH-015 (alias): Verify reset code reaches the new password screen
func test03_verifyResetCodeSuccess() throws {
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
let session = try XCTUnwrap(testSession)
// Navigate to forgot password
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.tapForgotPassword()
// Enter email and send the reset code
let forgotScreen = ForgotPasswordScreen(app: app)
forgotScreen.waitForLoad()
forgotScreen.enterEmail(session.user.email)
forgotScreen.tapSendCode()
// Enter the debug verification code on the verify screen
let verifyScreen = VerifyResetCodeScreen(app: app)
verifyScreen.waitForLoad()
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
verifyScreen.tapVerify()
// The reset password screen should now appear
let resetScreen = ResetPasswordScreen(app: app)
try resetScreen.waitForLoad(timeout: loginTimeout)
}
// MARK: - AUTH-016 (alias): Full reset flow + login with new password
func test04_resetPasswordSuccessAndLogin() throws {
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
let session = try XCTUnwrap(testSession)
let newPassword = "NewPass9876!"
// Navigate to forgot password, then drive the complete 3-step reset flow
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.tapForgotPassword()
try TestFlows.completeForgotPasswordFlow(
app: app,
email: session.user.email,
newPassword: newPassword
)
// Wait for a success indication either a success message or the return-to-login button
let successText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'success' OR label CONTAINS[c] 'reset'")
).firstMatch
let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton]
// After reset, the app auto-logs in with the new password.
// If auto-login succeeds app goes to main tabs. If fails return button appears.
let tabBar = app.tabBars.firstMatch
let deadline = Date().addingTimeInterval(loginTimeout)
var reachedPostReset = false
while Date() < deadline {
if tabBar.exists {
reachedPostReset = true
break
}
if returnButton.exists {
reachedPostReset = true
returnButton.forceTap()
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
XCTAssertTrue(reachedPostReset, "Expected main tabs (auto-login) or return button after password reset")
if tabBar.exists { return }
// Manual login fallback
let loginScreen = LoginScreenObject(app: app)
loginScreen.waitForLoad(timeout: loginTimeout)
loginScreen.enterUsername(session.username)
loginScreen.enterPassword(newPassword)
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitUntilHittable(timeout: 10).tap()
XCTAssertTrue(tabBar.waitForExistence(timeout: loginTimeout), "Should login successfully with new password")
}
// MARK: - AUTH-017: Mismatched passwords are blocked
func testAUTH017_MismatchedPasswordBlocked() throws {
let session = try XCTUnwrap(testSession)
// Navigate to forgot password
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.tapForgotPassword()
// Get to the reset password screen
let forgotScreen = ForgotPasswordScreen(app: app)
forgotScreen.waitForLoad()
forgotScreen.enterEmail(session.user.email)
forgotScreen.tapSendCode()
let verifyScreen = VerifyResetCodeScreen(app: app)
verifyScreen.waitForLoad()
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
verifyScreen.tapVerify()
// Enter mismatched passwords
let resetScreen = ResetPasswordScreen(app: app)
try resetScreen.waitForLoad(timeout: loginTimeout)
resetScreen.enterNewPassword("ValidPass123!")
resetScreen.enterConfirmPassword("DifferentPass456!")
// The reset button should be disabled when passwords don't match
XCTAssertFalse(resetScreen.isResetButtonEnabled, "Reset button should be disabled when passwords don't match")
}
}
@@ -1,22 +0,0 @@
import XCTest
/// Rebuild plan for legacy: Suite0_OnboardingTests.test_onboarding
/// Split into smaller tests to isolate focus/input/navigation failures.
final class Suite0_OnboardingRebuildTests: BaseUITestCase {
override var relaunchBetweenTests: Bool { true }
func testR001_onboardingWelcomeLoadsAndCanNavigateToLoginEntry() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad(timeout: defaultTimeout)
welcome.tapAlreadyHaveAccount()
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
}
func testR002_startFreshFlowReachesCreateAccount() {
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Rebuild Home")
createAccount.waitForLoad(timeout: defaultTimeout)
}
}
@@ -1,149 +0,0 @@
import XCTest
/// Rebuild plan for legacy Suite2 failures:
/// - test02_loginWithValidCredentials
/// - test06_logout
final class Suite2_AuthenticationRebuildTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
override var relaunchBetweenTests: Bool { true }
private let validUser = RebuildTestUserFactory.seeded
private enum AuthLandingState {
case main
case verification
}
override func setUpWithError() throws {
// Force a clean app launch so no stale field text persists between tests
app.terminate()
try super.setUpWithError()
UITestHelpers.ensureLoggedOut(app: app)
}
private func loginFromLoginScreen(user: RebuildTestUser = RebuildTestUserFactory.seeded) {
UITestHelpers.ensureOnLoginScreen(app: app)
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.enterUsername(user.username)
login.enterPassword(user.password)
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
loginButton.forceTap()
}
@discardableResult
private func loginAndWaitForAuthenticatedLanding(user: RebuildTestUser = RebuildTestUserFactory.seeded) -> AuthLandingState {
loginFromLoginScreen(user: user)
let mainRoot = app.otherElements[UITestID.Root.mainTabs]
if mainRoot.waitForExistence(timeout: loginTimeout) || app.tabBars.firstMatch.waitForExistence(timeout: 2) {
return .main
}
let verification = VerificationScreen(app: app)
if verification.codeField.waitForExistence(timeout: defaultTimeout) || verification.verifyButton.waitForExistence(timeout: 2) {
return .verification
}
XCTFail("Expected authenticated landing on main tabs or verification screen")
return .verification
}
private func logoutFromVerificationIfNeeded() {
let verification = VerificationScreen(app: app)
verification.waitForLoad(timeout: defaultTimeout)
verification.tapLogoutIfAvailable()
let toolbarLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
if toolbarLogout.waitForExistence(timeout: 3) {
toolbarLogout.forceTap()
}
}
private func logoutFromMainApp() {
UITestHelpers.logout(app: app)
}
func testR201_loginScreenLoadsFromOnboardingEntry() {
UITestHelpers.ensureOnLoginScreen(app: app)
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
}
func testR202_validCredentialsSubmitFromLogin() {
UITestHelpers.ensureOnLoginScreen(app: app)
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.enterUsername(validUser.username)
login.enterPassword(validUser.password)
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
XCTAssertTrue(loginButton.waitForExistence(timeout: defaultTimeout), "Login button must exist before submit")
XCTAssertTrue(loginButton.isHittable, "Login button must be tappable")
}
func testR203_validLoginTransitionsToMainAppRoot() {
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
switch landing {
case .main:
RebuildSessionAssertions.assertOnMainApp(app, timeout: loginTimeout)
case .verification:
RebuildSessionAssertions.assertOnVerification(app, timeout: loginTimeout)
}
}
func testR204_mainAppHasExpectedPrimaryTabsAfterLogin() {
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
switch landing {
case .main:
RebuildSessionAssertions.assertOnMainApp(app, timeout: loginTimeout)
let tabBar = app.tabBars.firstMatch
if tabBar.waitForExistence(timeout: 5) {
let residences = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
let tasks = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
let contractors = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
let docs = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Doc'")).firstMatch
XCTAssertTrue(residences.exists, "Residences tab should exist")
XCTAssertTrue(tasks.exists, "Tasks tab should exist")
XCTAssertTrue(contractors.exists, "Contractors tab should exist")
XCTAssertTrue(docs.exists, "Documents tab should exist")
} else {
XCTAssertTrue(app.otherElements[UITestID.Root.mainTabs].exists, "Main tabs root should exist")
}
case .verification:
let verify = VerificationScreen(app: app)
verify.waitForLoad(timeout: defaultTimeout)
XCTAssertTrue(verify.codeField.exists, "Verification code field should exist for unverified accounts")
}
}
func testR205_logoutFromMainAppReturnsToLoginRoot() {
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
switch landing {
case .main:
logoutFromMainApp()
case .verification:
logoutFromVerificationIfNeeded()
}
RebuildSessionAssertions.assertOnLogin(app, timeout: loginTimeout)
}
func testR206_postLogoutMainAppIsNoLongerAccessible() {
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
switch landing {
case .main:
logoutFromMainApp()
case .verification:
logoutFromVerificationIfNeeded()
}
RebuildSessionAssertions.assertOnLogin(app, timeout: loginTimeout)
XCTAssertFalse(app.otherElements[UITestID.Root.mainTabs].exists, "Main app root should not be visible after logout")
}
}
@@ -1,157 +0,0 @@
import XCTest
/// Rebuild plan for legacy Suite3 failures (all blocked at residences tab precondition).
/// Old tests covered:
/// - test01_viewResidencesList
/// - test02_navigateToAddResidence
/// - test03_navigationBetweenTabs
/// - test04_cancelResidenceCreation
/// - test05_createResidenceWithMinimalData
/// - test06_viewResidenceDetails
final class Suite3_ResidenceRebuildTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
override var relaunchBetweenTests: Bool { true }
override func setUpWithError() throws {
// Force a clean app launch so no stale field text persists between tests
app.terminate()
try super.setUpWithError()
UITestHelpers.ensureLoggedOut(app: app)
}
private func loginAndOpenResidences() {
UITestHelpers.ensureOnLoginScreen(app: app)
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.enterUsername("testuser")
login.enterPassword("TestPass123!")
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].waitForExistenceOrFail(timeout: defaultTimeout).forceTap()
// Wait for either main tabs or verification screen
let main = MainTabScreenObject(app: app)
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
let tabBar = app.tabBars.firstMatch
let verificationScreen = VerificationScreen(app: app)
let deadline = Date().addingTimeInterval(loginTimeout)
while Date() < deadline {
if mainTabs.exists || tabBar.exists {
break
}
if verificationScreen.codeField.exists {
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
verificationScreen.submitCode()
_ = mainTabs.waitForExistence(timeout: loginTimeout) || tabBar.waitForExistence(timeout: 5)
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
XCTAssertTrue(mainTabs.exists || tabBar.exists, "Expected main app root to appear after login (with verification handling)")
main.goToResidences()
}
@discardableResult
private func createResidence(name: String) -> String {
loginAndOpenResidences()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
list.openCreateResidence()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
form.enterName(name)
form.save()
return name
}
func testR301_authenticatedPreconditionCanReachMainApp() throws {
loginAndOpenResidences()
RebuildSessionAssertions.assertOnMainApp(app, timeout: defaultTimeout)
}
func testR302_residencesTabIsPresentAndNavigable() throws {
loginAndOpenResidences()
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
}
func testR303_residencesListLoadsAfterTabSelection() throws {
loginAndOpenResidences()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
XCTAssertTrue(list.addButton.exists, "Add residence button should be visible")
}
func testR304_openAddResidenceFormFromResidencesList() throws {
loginAndOpenResidences()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
list.openCreateResidence()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
XCTAssertTrue(form.saveButton.exists, "Residence save button should exist")
}
func testR305_cancelAddResidenceReturnsToResidenceList() throws {
loginAndOpenResidences()
let list = ResidenceListScreen(app: app)
list.openCreateResidence()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
form.cancel()
list.waitForLoad(timeout: defaultTimeout)
}
func testR306_createResidenceMinimalDataSubmitsSuccessfully() throws {
let name = "UITest Home \(Int(Date().timeIntervalSince1970))"
_ = createResidence(name: name)
let created = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
XCTAssertTrue(created.waitForExistence(timeout: loginTimeout), "Created residence should appear in list")
}
func testR307_newResidenceAppearsInResidenceList() throws {
let name = "UITest Verify \(Int(Date().timeIntervalSince1970))"
_ = createResidence(name: name)
let created = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
XCTAssertTrue(created.waitForExistence(timeout: loginTimeout), "New residence should be visible in residences list")
}
func testR308_openResidenceDetailsFromResidenceList() throws {
let name = "UITest Detail \(Int(Date().timeIntervalSince1970))"
_ = createResidence(name: name)
let row = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
row.waitForExistenceOrFail(timeout: loginTimeout).forceTap()
let edit = app.buttons[AccessibilityIdentifiers.Residence.editButton]
let delete = app.buttons[AccessibilityIdentifiers.Residence.deleteButton]
let loaded = edit.waitForExistence(timeout: defaultTimeout) || delete.waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(loaded, "Residence details should expose edit or delete actions")
}
func testR309_navigationAcrossPrimaryTabsAndBackToResidences() throws {
loginAndOpenResidences()
let tabBar = app.tabBars.firstMatch
tabBar.waitForExistenceOrFail(timeout: defaultTimeout)
let tasksTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist")
tasksTab.forceTap()
let contractorsTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist")
contractorsTab.forceTap()
let residencesTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
residencesTab.forceTap()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
}
}
@@ -1,234 +0,0 @@
import XCTest
/// Integration tests for residence CRUD against the real local backend.
///
/// Uses a seeded admin account. Data is seeded via API and cleaned up in tearDown.
final class ResidenceIntegrationTests: AuthenticatedUITestCase {
override var needsAPISession: Bool { true }
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
// MARK: - Create Residence
func testRES_CreateResidenceAppearsInList() {
navigateToResidences()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
residenceList.openCreateResidence()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
let uniqueName = "IntTest Residence \(Int(Date().timeIntervalSince1970))"
form.enterName(uniqueName)
form.save()
let newResidence = app.staticTexts[uniqueName]
XCTAssertTrue(
newResidence.waitForExistence(timeout: loginTimeout),
"Newly created residence should appear in the list"
)
}
// MARK: - Edit Residence
func testRES_EditResidenceUpdatesInList() {
// Seed a residence via API so we have a known target to edit
let seeded = cleaner.seedResidence(name: "Edit Target \(Int(Date().timeIntervalSince1970))")
navigateToResidences()
pullToRefresh()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
// Find and tap the seeded residence
let card = app.staticTexts[seeded.name]
pullToRefreshUntilVisible(card, maxRetries: 3)
card.waitForExistenceOrFail(timeout: loginTimeout)
card.forceTap()
// Tap edit button on detail view
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
editButton.forceTap()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
// Clear and re-enter name
let nameField = form.nameField
nameField.waitUntilHittable(timeout: 10).tap()
nameField.press(forDuration: 1.0)
let selectAll = app.menuItems["Select All"]
if selectAll.waitForExistence(timeout: 2) {
selectAll.tap()
}
let updatedName = "Updated Res \(Int(Date().timeIntervalSince1970))"
nameField.typeText(updatedName)
form.save()
let updatedText = app.staticTexts[updatedName]
XCTAssertTrue(
updatedText.waitForExistence(timeout: loginTimeout),
"Updated residence name should appear after edit"
)
}
// MARK: - RES-007: Primary Residence
func test18_setPrimaryResidence() {
// Seed two residences via API; the second one will be promoted to primary
let firstResidence = cleaner.seedResidence(name: "Primary Test A \(Int(Date().timeIntervalSince1970))")
let secondResidence = cleaner.seedResidence(name: "Primary Test B \(Int(Date().timeIntervalSince1970))")
navigateToResidences()
pullToRefresh()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
// Open the second residence's detail
let secondCard = app.staticTexts[secondResidence.name]
pullToRefreshUntilVisible(secondCard, maxRetries: 3)
secondCard.waitForExistenceOrFail(timeout: loginTimeout)
secondCard.forceTap()
// Tap edit
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
editButton.forceTap()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
// Find and toggle the "is primary" toggle
let isPrimaryToggle = app.switches[AccessibilityIdentifiers.Residence.isPrimaryToggle]
isPrimaryToggle.scrollIntoView(in: app.scrollViews.firstMatch)
isPrimaryToggle.waitForExistenceOrFail(timeout: defaultTimeout)
// Toggle it on (value "0" means off, "1" means on)
if (isPrimaryToggle.value as? String) == "0" {
isPrimaryToggle.forceTap()
}
form.save()
// After saving, a primary indicator should be visible either a label,
// badge, or the toggle being on in the refreshed detail view.
let primaryIndicator = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Primary'")
).firstMatch
let primaryBadge = app.images.containing(
NSPredicate(format: "label CONTAINS[c] 'Primary'")
).firstMatch
let indicatorVisible = primaryIndicator.waitForExistence(timeout: loginTimeout)
|| primaryBadge.waitForExistence(timeout: 3)
XCTAssertTrue(
indicatorVisible,
"A primary residence indicator should appear after setting '\(secondResidence.name)' as primary"
)
// Clean up: remove unused firstResidence id from tracking (already tracked via cleaner)
_ = firstResidence
}
// MARK: - OFF-004: Double Submit Protection
func test19_doubleSubmitProtection() {
navigateToResidences()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
residenceList.openCreateResidence()
let form = ResidenceFormScreen(app: app)
form.waitForLoad(timeout: defaultTimeout)
let uniqueName = "DoubleSubmit \(Int(Date().timeIntervalSince1970))"
form.enterName(uniqueName)
// Rapidly tap save twice to test double-submit protection
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton]
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
saveButton.forceTap()
// Second tap immediately after if the button is already disabled this will be a no-op
if saveButton.isHittable {
saveButton.forceTap()
}
// Wait for the form to dismiss (sheet closes, we return to the list)
let formDismissed = saveButton.waitForNonExistence(timeout: loginTimeout)
XCTAssertTrue(formDismissed, "Form should dismiss after save")
// Back on the residences list count how many cells with the unique name exist
let matchingTexts = app.staticTexts.matching(
NSPredicate(format: "label == %@", uniqueName)
)
// Allow time for the list to fully load
_ = app.staticTexts[uniqueName].waitForExistence(timeout: defaultTimeout)
XCTAssertEqual(
matchingTexts.count, 1,
"Only one residence named '\(uniqueName)' should exist — double-submit protection should prevent duplicates"
)
// Track the created residence for cleanup
if let residences = TestAccountAPIClient.listResidences(token: session.token) {
if let created = residences.first(where: { $0.name == uniqueName }) {
cleaner.trackResidence(created.id)
}
}
}
// MARK: - Delete Residence
func testRES_DeleteResidenceRemovesFromList() {
// Seed a residence via API don't track it since we'll delete through the UI
let deleteName = "Delete Me \(Int(Date().timeIntervalSince1970))"
TestDataSeeder.createResidence(token: session.token, name: deleteName)
navigateToResidences()
pullToRefresh()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
// Find and tap the seeded residence
let target = app.staticTexts[deleteName]
pullToRefreshUntilVisible(target, maxRetries: 3)
target.waitForExistenceOrFail(timeout: loginTimeout)
target.forceTap()
// Tap delete button
let deleteButton = app.buttons[AccessibilityIdentifiers.Residence.deleteButton]
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
deleteButton.forceTap()
// Confirm deletion in alert
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 deletedResidence = app.staticTexts[deleteName]
XCTAssertTrue(
deletedResidence.waitForNonExistence(timeout: loginTimeout),
"Deleted residence should no longer appear in the list"
)
}
}
@@ -1,99 +0,0 @@
import XCTest
final class StabilityTests: BaseUITestCase {
func testP001_RapidOnboardingNavigationDoesNotCrash() {
for _ in 0..<3 {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad(timeout: defaultTimeout)
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad(timeout: defaultTimeout)
valueProps.tapBack()
welcome.waitForLoad(timeout: defaultTimeout)
}
}
func testP002_RepeatedForwardNavigationRemainsResponsive() {
for index in 0..<3 {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad(timeout: defaultTimeout)
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad(timeout: defaultTimeout)
valueProps.tapContinue()
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad(timeout: defaultTimeout)
nameResidence.enterResidenceName("Stress Home \(index)")
nameResidence.tapBack()
valueProps.waitForLoad(timeout: defaultTimeout)
valueProps.tapBack()
welcome.waitForLoad(timeout: defaultTimeout)
}
}
func testP003_RapidDoubleTapOnValuePropsContinueLandsOnNameResidence() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad(timeout: defaultTimeout)
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad(timeout: defaultTimeout)
let continueButton = app.buttons[UITestID.Onboarding.valuePropsNextButton]
continueButton.waitUntilHittable(timeout: defaultTimeout).tap()
if continueButton.exists && continueButton.isHittable {
continueButton.tap()
}
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad(timeout: defaultTimeout)
}
// MARK: - Additional Stability Coverage
func testP004_StartFreshThenBackToWelcomeThenJoinExistingDoesNotCorruptState() {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad(timeout: defaultTimeout)
// Start fresh path
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad(timeout: defaultTimeout)
// Go back to welcome
valueProps.tapBack()
welcome.waitForLoad(timeout: defaultTimeout)
// Switch to join existing path
welcome.tapJoinExisting()
let createAccount = OnboardingCreateAccountScreen(app: app)
createAccount.waitForLoad(timeout: defaultTimeout)
}
func testP005_RepeatedLoginNavigationRemainsStable() {
for _ in 0..<3 {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
// Dismiss login (swipe down or navigate back)
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
if backButton.waitForExistence(timeout: defaultTimeout) && backButton.isHittable {
backButton.forceTap()
} else {
// Try swipe down to dismiss sheet
app.swipeDown()
}
// Should return to onboarding
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad(timeout: defaultTimeout)
}
}
}
@@ -1,214 +0,0 @@
import XCTest
/// Integration tests for task operations against the real local backend.
///
/// Test Plan IDs: TASK-010, TASK-012, plus create/edit flows.
/// Data is seeded via API and cleaned up in tearDown.
final class TaskIntegrationTests: AuthenticatedUITestCase {
override var needsAPISession: Bool { true }
override var testCredentials: (username: String, password: String) { ("admin", "Test1234") }
override var apiCredentials: (username: String, password: String) { ("admin", "Test1234") }
// MARK: - Create Task
func testTASK_CreateTaskAppearsInList() {
// Seed a residence via API so task creation has a valid target
let residence = cleaner.seedResidence()
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: - TASK-010: Uncancel Task
func testTASK010_UncancelTaskFlow() throws {
// Seed a cancelled task via API
let residence = cleaner.seedResidence()
let cancelledTask = TestDataSeeder.createCancelledTask(token: session.token, residenceId: residence.id)
cleaner.trackTask(cancelledTask.id)
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 {
// Seed a residence and a task, then cancel the task via API
let residence = cleaner.seedResidence(name: "Uncancel Test Residence \(Int(Date().timeIntervalSince1970))")
let task = cleaner.seedTask(residenceId: residence.id, title: "Uncancel Me \(Int(Date().timeIntervalSince1970))")
guard TestAccountAPIClient.cancelTask(token: session.token, id: task.id) != nil else {
throw XCTSkip("Could not cancel task via API — skipping uncancel test")
}
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)
let residence = cleaner.seedResidence()
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"
)
}
}