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:
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user