Rebrand from Casera/MyCrib to honeyDue

Total rebrand across KMM project:
- Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations)
- Gradle: rootProject.name, namespace, applicationId
- Android: manifest, strings.xml (all languages), widget resources
- iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig
- iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc.
- Swift source: all class/struct/enum renames
- Deep links: casera:// -> honeydue://, .casera -> .honeydue
- App icons replaced with honeyDue honeycomb icon
- Domains: casera.treytartt.com -> honeyDue.treytartt.com
- Bundle IDs: com.tt.casera -> com.tt.honeyDue
- Database table names preserved

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-07 06:33:57 -06:00
parent 9c574c4343
commit 1e2adf7660
450 changed files with 1730 additions and 1788 deletions

View File

@@ -0,0 +1,274 @@
import Foundation
/// Centralized accessibility identifiers for UI testing
/// These identifiers are used by XCUITests to locate and interact with UI elements
struct AccessibilityIdentifiers {
// MARK: - Authentication
struct Authentication {
static let usernameField = "Login.UsernameField"
static let passwordField = "Login.PasswordField"
static let loginButton = "Login.LoginButton"
static let signUpButton = "Login.SignUpButton"
static let forgotPasswordButton = "Login.ForgotPasswordButton"
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
static let appleSignInButton = "Login.AppleSignInButton"
static let googleSignInButton = "Login.GoogleSignInButton"
// Registration
static let registerUsernameField = "Register.UsernameField"
static let registerEmailField = "Register.EmailField"
static let registerPasswordField = "Register.PasswordField"
static let registerConfirmPasswordField = "Register.ConfirmPasswordField"
static let registerButton = "Register.RegisterButton"
static let registerCancelButton = "Register.CancelButton"
// Verification
static let verificationCodeField = "Verification.CodeField"
static let verifyButton = "Verification.VerifyButton"
static let resendCodeButton = "Verification.ResendButton"
}
// MARK: - Navigation
struct Navigation {
static let residencesTab = "TabBar.Residences"
static let tasksTab = "TabBar.Tasks"
static let contractorsTab = "TabBar.Contractors"
static let documentsTab = "TabBar.Documents"
static let profileTab = "TabBar.Profile"
static let settingsButton = "Navigation.SettingsButton"
static let backButton = "Navigation.BackButton"
}
// MARK: - Residence
struct Residence {
// List
static let addButton = "Residence.AddButton"
static let residencesList = "Residence.List"
static let residenceCard = "Residence.Card"
static let emptyStateView = "Residence.EmptyState"
static let emptyStateButton = "Residence.EmptyState.AddButton"
// Form
static let nameField = "ResidenceForm.NameField"
static let propertyTypePicker = "ResidenceForm.PropertyTypePicker"
static let streetAddressField = "ResidenceForm.StreetAddressField"
static let apartmentUnitField = "ResidenceForm.ApartmentUnitField"
static let cityField = "ResidenceForm.CityField"
static let stateProvinceField = "ResidenceForm.StateProvinceField"
static let postalCodeField = "ResidenceForm.PostalCodeField"
static let countryField = "ResidenceForm.CountryField"
static let bedroomsField = "ResidenceForm.BedroomsField"
static let bathroomsField = "ResidenceForm.BathroomsField"
static let squareFootageField = "ResidenceForm.SquareFootageField"
static let lotSizeField = "ResidenceForm.LotSizeField"
static let yearBuiltField = "ResidenceForm.YearBuiltField"
static let descriptionField = "ResidenceForm.DescriptionField"
static let isPrimaryToggle = "ResidenceForm.IsPrimaryToggle"
static let saveButton = "ResidenceForm.SaveButton"
static let formCancelButton = "ResidenceForm.CancelButton"
// Detail
static let detailView = "ResidenceDetail.View"
static let editButton = "ResidenceDetail.EditButton"
static let deleteButton = "ResidenceDetail.DeleteButton"
static let shareButton = "ResidenceDetail.ShareButton"
static let manageUsersButton = "ResidenceDetail.ManageUsersButton"
static let tasksSection = "ResidenceDetail.TasksSection"
static let addTaskButton = "ResidenceDetail.AddTaskButton"
}
// MARK: - Task
struct Task {
// List/Kanban
static let addButton = "Task.AddButton"
static let tasksList = "Task.List"
static let taskCard = "Task.Card"
static let emptyStateView = "Task.EmptyState"
static let kanbanView = "Task.KanbanView"
static let overdueColumn = "Task.Column.Overdue"
static let upcomingColumn = "Task.Column.Upcoming"
static let inProgressColumn = "Task.Column.InProgress"
static let completedColumn = "Task.Column.Completed"
// Form
static let titleField = "TaskForm.TitleField"
static let descriptionField = "TaskForm.DescriptionField"
static let categoryPicker = "TaskForm.CategoryPicker"
static let frequencyPicker = "TaskForm.FrequencyPicker"
static let priorityPicker = "TaskForm.PriorityPicker"
static let statusPicker = "TaskForm.StatusPicker"
static let dueDatePicker = "TaskForm.DueDatePicker"
static let intervalDaysField = "TaskForm.IntervalDaysField"
static let estimatedCostField = "TaskForm.EstimatedCostField"
static let residencePicker = "TaskForm.ResidencePicker"
static let saveButton = "TaskForm.SaveButton"
static let formCancelButton = "TaskForm.CancelButton"
// Detail
static let detailView = "TaskDetail.View"
static let editButton = "TaskDetail.EditButton"
static let deleteButton = "TaskDetail.DeleteButton"
static let markInProgressButton = "TaskDetail.MarkInProgressButton"
static let completeButton = "TaskDetail.CompleteButton"
static let detailCancelButton = "TaskDetail.CancelButton"
// Completion
static let completionDatePicker = "TaskCompletion.CompletionDatePicker"
static let actualCostField = "TaskCompletion.ActualCostField"
static let ratingView = "TaskCompletion.RatingView"
static let notesField = "TaskCompletion.NotesField"
static let photosPicker = "TaskCompletion.PhotosPicker"
static let submitButton = "TaskCompletion.SubmitButton"
}
// MARK: - Contractor
struct Contractor {
static let addButton = "Contractor.AddButton"
static let contractorsList = "Contractor.List"
static let contractorCard = "Contractor.Card"
static let emptyStateView = "Contractor.EmptyState"
// Form
static let nameField = "ContractorForm.NameField"
static let companyField = "ContractorForm.CompanyField"
static let emailField = "ContractorForm.EmailField"
static let phoneField = "ContractorForm.PhoneField"
static let specialtyPicker = "ContractorForm.SpecialtyPicker"
static let ratingView = "ContractorForm.RatingView"
static let notesField = "ContractorForm.NotesField"
static let saveButton = "ContractorForm.SaveButton"
static let formCancelButton = "ContractorForm.CancelButton"
// Detail
static let detailView = "ContractorDetail.View"
static let editButton = "ContractorDetail.EditButton"
static let deleteButton = "ContractorDetail.DeleteButton"
static let callButton = "ContractorDetail.CallButton"
static let emailButton = "ContractorDetail.EmailButton"
}
// MARK: - Document
struct Document {
static let addButton = "Document.AddButton"
static let documentsList = "Document.List"
static let documentCard = "Document.Card"
static let emptyStateView = "Document.EmptyState"
// Form
static let titleField = "DocumentForm.TitleField"
static let typePicker = "DocumentForm.TypePicker"
static let categoryPicker = "DocumentForm.CategoryPicker"
static let residencePicker = "DocumentForm.ResidencePicker"
static let filePicker = "DocumentForm.FilePicker"
static let notesField = "DocumentForm.NotesField"
static let expirationDatePicker = "DocumentForm.ExpirationDatePicker"
static let saveButton = "DocumentForm.SaveButton"
static let formCancelButton = "DocumentForm.CancelButton"
// Detail
static let detailView = "DocumentDetail.View"
static let editButton = "DocumentDetail.EditButton"
static let deleteButton = "DocumentDetail.DeleteButton"
static let shareButton = "DocumentDetail.ShareButton"
static let downloadButton = "DocumentDetail.DownloadButton"
}
// MARK: - Onboarding
struct Onboarding {
// Welcome Screen
static let welcomeTitle = "Onboarding.WelcomeTitle"
static let startFreshButton = "Onboarding.StartFreshButton"
static let joinExistingButton = "Onboarding.JoinExistingButton"
static let loginButton = "Onboarding.LoginButton"
// Value Props Screen
static let valuePropsTitle = "Onboarding.ValuePropsTitle"
static let valuePropsNextButton = "Onboarding.ValuePropsNextButton"
// Name Residence Screen
static let nameResidenceTitle = "Onboarding.NameResidenceTitle"
static let residenceNameField = "Onboarding.ResidenceNameField"
static let nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton"
// Create Account Screen
static let createAccountTitle = "Onboarding.CreateAccountTitle"
static let appleSignInButton = "Onboarding.AppleSignInButton"
static let emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton"
static let usernameField = "Onboarding.UsernameField"
static let emailField = "Onboarding.EmailField"
static let passwordField = "Onboarding.PasswordField"
static let confirmPasswordField = "Onboarding.ConfirmPasswordField"
static let createAccountButton = "Onboarding.CreateAccountButton"
static let loginLinkButton = "Onboarding.LoginLinkButton"
// Verify Email Screen
static let verifyEmailTitle = "Onboarding.VerifyEmailTitle"
static let verificationCodeField = "Onboarding.VerificationCodeField"
static let verifyButton = "Onboarding.VerifyButton"
// Join Residence Screen
static let joinResidenceTitle = "Onboarding.JoinResidenceTitle"
static let shareCodeField = "Onboarding.ShareCodeField"
static let joinResidenceButton = "Onboarding.JoinResidenceButton"
// First Task Screen
static let firstTaskTitle = "Onboarding.FirstTaskTitle"
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
static let addTasksContinueButton = "Onboarding.AddTasksContinueButton"
static let taskCategorySection = "Onboarding.TaskCategorySection"
static let taskTemplateRow = "Onboarding.TaskTemplateRow"
// Subscription Screen
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
static let yearlyPlanCard = "Onboarding.YearlyPlanCard"
static let monthlyPlanCard = "Onboarding.MonthlyPlanCard"
static let startTrialButton = "Onboarding.StartTrialButton"
static let continueWithFreeButton = "Onboarding.ContinueWithFreeButton"
// Navigation
static let backButton = "Onboarding.BackButton"
static let skipButton = "Onboarding.SkipButton"
static let progressIndicator = "Onboarding.ProgressIndicator"
}
// MARK: - Profile
struct Profile {
static let logoutButton = "Profile.LogoutButton"
static let editProfileButton = "Profile.EditProfileButton"
static let settingsButton = "Profile.SettingsButton"
static let notificationsToggle = "Profile.NotificationsToggle"
static let darkModeToggle = "Profile.DarkModeToggle"
static let aboutButton = "Profile.AboutButton"
static let helpButton = "Profile.HelpButton"
}
// MARK: - Alerts & Modals
struct Alert {
static let confirmButton = "Alert.ConfirmButton"
static let cancelButton = "Alert.CancelButton"
static let deleteButton = "Alert.DeleteButton"
static let okButton = "Alert.OKButton"
}
// MARK: - Common
struct Common {
static let loadingIndicator = "Common.LoadingIndicator"
static let errorView = "Common.ErrorView"
static let retryButton = "Common.RetryButton"
static let searchField = "Common.SearchField"
static let filterButton = "Common.FilterButton"
static let sortButton = "Common.SortButton"
static let refreshControl = "Common.RefreshControl"
}
}
// MARK: - Helper Extension
extension String {
/// Convenience method to generate dynamic identifiers
/// Example: "Residence.Card.\(residenceId)"
func withId(_ id: Any) -> String {
return "\(self).\(id)"
}
}

View File

@@ -0,0 +1,111 @@
import XCTest
/// Critical path tests for authentication flows.
///
/// Validates login, logout, registration entry, and password reset entry.
/// Zero sleep() calls all waits are condition-based.
final class AuthCriticalPathTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = TestLaunchConfig.launchApp()
}
override func tearDown() {
app = nil
super.tearDown()
}
// MARK: - Login
func testLoginWithValidCredentials() {
let login = LoginScreen(app: app)
guard login.emailField.waitForExistence(timeout: 15) else {
// Already logged in verify main screen
let main = MainTabScreen(app: app)
XCTAssertTrue(main.isDisplayed, "Main screen should be visible when already logged in")
return
}
let user = TestFixtures.TestUser.existing
login.login(email: user.email, password: user.password)
let main = MainTabScreen(app: app)
XCTAssertTrue(
main.residencesTab.waitForExistence(timeout: 15),
"Should navigate to main screen after successful login"
)
}
func testLoginWithInvalidCredentials() {
let login = LoginScreen(app: app)
guard login.emailField.waitForExistence(timeout: 15) else {
return // Already logged in, skip
}
login.login(email: "invaliduser", password: "wrongpassword")
// Should stay on login screen email field should still exist
XCTAssertTrue(
login.emailField.waitForExistence(timeout: 10),
"Should remain on login screen after invalid credentials"
)
// Tab bar should NOT appear
let tabBar = app.tabBars.firstMatch
XCTAssertFalse(tabBar.exists, "Tab bar should not appear after failed login")
}
// MARK: - Logout
func testLogoutFlow() {
let login = LoginScreen(app: app)
if login.emailField.waitForExistence(timeout: 15) {
let user = TestFixtures.TestUser.existing
login.login(email: user.email, password: user.password)
}
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 15) else {
XCTFail("Main screen did not appear")
return
}
main.logout()
// Should be back on login screen
let loginAfterLogout = LoginScreen(app: app)
XCTAssertTrue(
loginAfterLogout.emailField.waitForExistence(timeout: 15),
"Should return to login screen after logout"
)
}
// MARK: - Registration Entry
func testSignUpButtonNavigatesToRegistration() {
let login = LoginScreen(app: app)
guard login.emailField.waitForExistence(timeout: 15) else {
return // Already logged in, skip
}
let register = login.tapSignUp()
XCTAssertTrue(register.isDisplayed, "Registration screen should appear after tapping Sign Up")
}
// MARK: - Forgot Password Entry
func testForgotPasswordButtonExists() {
let login = LoginScreen(app: app)
guard login.emailField.waitForExistence(timeout: 15) else {
return // Already logged in, skip
}
XCTAssertTrue(
login.forgotPasswordButton.waitForExistence(timeout: 5),
"Forgot password button should exist on login screen"
)
}
}

View File

@@ -0,0 +1,169 @@
import XCTest
/// Critical path tests for core navigation.
///
/// Validates tab bar navigation, settings access, and screen transitions.
/// Requires a logged-in user. Zero sleep() calls all waits are condition-based.
final class NavigationCriticalPathTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = TestLaunchConfig.launchApp()
ensureLoggedIn()
}
override func tearDown() {
app = nil
super.tearDown()
}
private func ensureLoggedIn() {
let login = LoginScreen(app: app)
if login.emailField.waitForExistence(timeout: 15) {
let user = TestFixtures.TestUser.existing
login.login(email: user.email, password: user.password)
}
let main = MainTabScreen(app: app)
_ = main.residencesTab.waitForExistence(timeout: 15)
}
// MARK: - Tab Navigation
func testAllTabsExist() {
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 10) else {
XCTFail("Main screen did not appear")
return
}
XCTAssertTrue(main.residencesTab.exists, "Residences tab should exist")
XCTAssertTrue(main.tasksTab.exists, "Tasks tab should exist")
XCTAssertTrue(main.contractorsTab.exists, "Contractors tab should exist")
XCTAssertTrue(main.documentsTab.exists, "Documents tab should exist")
}
func testNavigateToTasksTab() {
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 10) else {
XCTFail("Main screen did not appear")
return
}
main.goToTasks()
XCTAssertTrue(main.tasksTab.isSelected, "Tasks tab should be selected")
}
func testNavigateToContractorsTab() {
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 10) else {
XCTFail("Main screen did not appear")
return
}
main.goToContractors()
XCTAssertTrue(main.contractorsTab.isSelected, "Contractors tab should be selected")
}
func testNavigateToDocumentsTab() {
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 10) else {
XCTFail("Main screen did not appear")
return
}
main.goToDocuments()
XCTAssertTrue(main.documentsTab.isSelected, "Documents tab should be selected")
}
func testNavigateBackToResidencesTab() {
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 10) else {
XCTFail("Main screen did not appear")
return
}
main.goToDocuments()
main.goToResidences()
XCTAssertTrue(main.residencesTab.isSelected, "Residences tab should be selected")
}
// MARK: - Settings Access
func testSettingsButtonExists() {
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 10) else {
XCTFail("Main screen did not appear")
return
}
main.goToResidences()
XCTAssertTrue(
main.settingsButton.waitForExistence(timeout: 5),
"Settings button should exist on Residences screen"
)
}
// MARK: - Add Buttons
func testResidenceAddButtonExists() {
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 10) else {
XCTFail("Main screen did not appear")
return
}
main.goToResidences()
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
XCTAssertTrue(
addButton.waitForExistence(timeout: 5),
"Residence add button should exist"
)
}
func testTaskAddButtonExists() {
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 10) else {
XCTFail("Main screen did not appear")
return
}
main.goToTasks()
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
XCTAssertTrue(
addButton.waitForExistence(timeout: 5),
"Task add button should exist"
)
}
func testContractorAddButtonExists() {
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 10) else {
XCTFail("Main screen did not appear")
return
}
main.goToContractors()
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton]
XCTAssertTrue(
addButton.waitForExistence(timeout: 5),
"Contractor add button should exist"
)
}
func testDocumentAddButtonExists() {
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 10) else {
XCTFail("Main screen did not appear")
return
}
main.goToDocuments()
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton]
XCTAssertTrue(
addButton.waitForExistence(timeout: 5),
"Document add button should exist"
)
}
}

View File

@@ -0,0 +1,118 @@
import XCTest
/// Smoke tests - run on every PR. Must complete in <2 minutes.
///
/// Tests that the app launches successfully, the auth screen renders correctly,
/// and core navigation is functional. These are the minimum-viability tests
/// that must pass before any PR can merge.
///
/// Zero sleep() calls all waits are condition-based.
final class SmokeTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = TestLaunchConfig.launchApp()
}
override func tearDown() {
app = nil
super.tearDown()
}
// MARK: - App Launch
func testAppLaunches() {
// App should show either login screen or main tab view
let loginScreen = LoginScreen(app: app)
let mainScreen = MainTabScreen(app: app)
let loginAppeared = loginScreen.emailField.waitForExistence(timeout: 15)
let mainAppeared = mainScreen.residencesTab.waitForExistence(timeout: 5)
XCTAssertTrue(loginAppeared || mainAppeared, "App should show login or main screen on launch")
}
// MARK: - Login Screen Elements
func testLoginScreenElements() {
let login = LoginScreen(app: app)
guard login.emailField.waitForExistence(timeout: 15) else {
// Already logged in, skip this test
return
}
XCTAssertTrue(login.emailField.exists, "Email field should exist")
XCTAssertTrue(login.passwordField.exists, "Password field should exist")
XCTAssertTrue(login.loginButton.exists, "Login button should exist")
}
// MARK: - Login Flow
func testLoginWithExistingCredentials() {
let login = LoginScreen(app: app)
guard login.emailField.waitForExistence(timeout: 15) else {
// Already on main screen - verify tabs
let main = MainTabScreen(app: app)
XCTAssertTrue(main.isDisplayed, "Main tabs should be visible")
return
}
// Login with the known test user
let user = TestFixtures.TestUser.existing
login.login(email: user.email, password: user.password)
let main = MainTabScreen(app: app)
XCTAssertTrue(main.residencesTab.waitForExistence(timeout: 15), "Should navigate to main screen after login")
}
// MARK: - Tab Navigation
func testMainTabsExistAfterLogin() {
let login = LoginScreen(app: app)
if login.emailField.waitForExistence(timeout: 15) {
let user = TestFixtures.TestUser.existing
login.login(email: user.email, password: user.password)
}
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 15) else {
XCTFail("Main screen did not appear")
return
}
// App has 4 tabs: Residences, Tasks, Contractors, Documents
XCTAssertTrue(main.residencesTab.exists, "Residences tab should exist")
XCTAssertTrue(main.tasksTab.exists, "Tasks tab should exist")
XCTAssertTrue(main.contractorsTab.exists, "Contractors tab should exist")
XCTAssertTrue(main.documentsTab.exists, "Documents tab should exist")
}
func testTabNavigation() {
let login = LoginScreen(app: app)
if login.emailField.waitForExistence(timeout: 15) {
let user = TestFixtures.TestUser.existing
login.login(email: user.email, password: user.password)
}
let main = MainTabScreen(app: app)
guard main.residencesTab.waitForExistence(timeout: 15) else {
XCTFail("Main screen did not appear")
return
}
// Navigate through each tab and verify selection
main.goToTasks()
XCTAssertTrue(main.tasksTab.isSelected, "Tasks tab should be selected")
main.goToContractors()
XCTAssertTrue(main.contractorsTab.isSelected, "Contractors tab should be selected")
main.goToDocuments()
XCTAssertTrue(main.documentsTab.isSelected, "Documents tab should be selected")
main.goToResidences()
XCTAssertTrue(main.residencesTab.isSelected, "Residences tab should be selected")
}
}

View File

@@ -0,0 +1,164 @@
# Failing Suites 0-3: Coverage + Rebuild Plan
## Baseline (from observed runs)
- `Suite0_OnboardingTests`: 1 test, 1 failure
- `Suite1_RegistrationTests`: 11 tests, 5 failures
- `Suite2_AuthenticationTests`: 6 tests, 2 failures
- `Suite3_ResidenceTests`: 6 tests, 6 failures
Primary failure logs used:
- `/tmp/ui_suite0.log`
- `/tmp/ui_suites_1_3.log`
---
## Suite0
### Failing test
- `Suite0_OnboardingTests.test_onboarding`
### What it is testing
- End-to-end onboarding progression from welcome/login entry into account creation and onward.
- UI interaction stability during onboarding form entry.
### Observed failure point
- Assertion failure: `Email field must become focused for typing`.
### Rebuild in new arch
Create a new test case focused on deterministic onboarding field interaction:
- `Onboarding_EmailRegistration_FocusAndInputFlow`
Coverage to preserve:
- Email field reliably focusable and typeable.
- Continue action only enabled after valid required inputs.
- Onboarding progresses to next state after valid submission.
Required infra:
- `OnboardingScreen` page object with `tapEmailField()`, `typeEmail()`, `assertEmailFieldFocused()`.
- Keyboard/overlay helper centralized (not inline in tests).
---
## Suite1
Detailed plan already captured in:
- `/Users/treyt/Desktop/code/HoneyDueKMM/iosApp/HoneyDueUITests/Docs/Suite1_Failing_Test_Rebuild_Plan.md`
### Failing tests
- `test07_successfulRegistrationAndVerification`
- `test09_registrationWithInvalidVerificationCode`
- `test10_verificationCodeFieldValidation`
- `test11_appRelaunchWithUnverifiedUser`
- `test12_logoutFromVerificationScreen`
### Rebuild targets
- `Registration_HappyPath_CompletesVerification_ThenCanLogout`
- `Registration_InvalidVerifyCode_ShowsError_StaysUnverified`
- `Registration_IncompleteVerifyCode_DoesNotVerify`
- `Registration_UnverifiedUser_RelaunchStillBlockedFromMain`
- `Registration_VerificationScreenLogout_ReturnsToLogin`
---
## Suite2
### Failing tests
- `Suite2_AuthenticationTests.test02_loginWithValidCredentials`
- `Suite2_AuthenticationTests.test06_logout`
### What they are testing
#### `test02_loginWithValidCredentials`
- Valid login path transitions from login screen to main app.
- Authenticated state exposes main navigation (tab bar/app root).
#### `test06_logout`
- Logged-in user can logout.
- Session is cleared and app returns to login state.
### Observed failure points
- `test02`: `Should navigate to main app after successful login`
- `test06`: `Should be logged in` (precondition for logout flow failed)
### Rebuild in new arch
Create explicit state-driven auth tests:
- `Auth_ValidLogin_TransitionsToMainApp`
- `Auth_Logout_FromMainApp_ReturnsToLogin`
Coverage to preserve:
- Login success sets authenticated UI state.
- Logout always clears authenticated state.
- No false-positive “logged in” assumptions.
Required infra:
- `LoginScreen`, `MainTabScreen`, `ProfileScreen` page objects.
- `AuthAssertions.assertAtLoginRoot()`, `assertAtMainRoot()`.
- Test user fixture policy for valid credentials.
---
## Suite3
### Failing tests
- `Suite3_ResidenceTests.test01_viewResidencesList`
- `Suite3_ResidenceTests.test02_navigateToAddResidence`
- `Suite3_ResidenceTests.test03_navigationBetweenTabs`
- `Suite3_ResidenceTests.test04_cancelResidenceCreation`
- `Suite3_ResidenceTests.test05_createResidenceWithMinimalData`
- `Suite3_ResidenceTests.test06_viewResidenceDetails`
### What they are testing
- Residence tab/list visibility.
- Navigation to add-residence form.
- Cross-tab navigation sanity.
- Canceling residence creation.
- Creating residence with minimal fields.
- Opening residence details.
### Observed failure pattern
All 6 fail at the same gateway:
- No `Residences` tab bar button match found.
- This indicates tests are not reaching authenticated main-app state before residence assertions.
### Rebuild in new arch
Split auth precondition from residence behavior:
- `Residence_Precondition_AuthenticatedAndAtResidencesTab`
- `Residence_OpenCreateForm`
- `Residence_CancelCreate_ReturnsToList`
- `Residence_CreateMinimal_ShowsInList`
- `Residence_OpenDetails_FromList`
- `Residence_TabNavigation_MainSections`
Coverage to preserve:
- Residence flows validated only after explicit `main app ready` assertion.
- Failures clearly classify as auth-gate vs residence-feature regression.
Required infra:
- `MainTabScreen.goToResidences()` with ID-first selectors.
- `ResidenceListScreen`, `ResidenceFormScreen`, `ResidenceDetailScreen` page objects.
- Shared precondition helper: `ensureAuthenticatedMainApp()`.
---
## Blueprint-aligned migration notes
- Keep old-to-new mapping explicit in PR description.
- Replace brittle text-based selectors with accessibility IDs first.
- Use one state assertion per transition boundary:
- `login -> verification -> main app -> login`.
- Move keyboard/strong-password overlay handling into one helper.
- Do not mark legacy tests removed until replacement coverage is green.
## Proposed replacement matrix
- `Suite0.test_onboarding` -> `Onboarding_EmailRegistration_FocusAndInputFlow`
- `Suite1.test07` -> `Registration_HappyPath_CompletesVerification_ThenCanLogout`
- `Suite1.test09` -> `Registration_InvalidVerifyCode_ShowsError_StaysUnverified`
- `Suite1.test10` -> `Registration_IncompleteVerifyCode_DoesNotVerify`
- `Suite1.test11` -> `Registration_UnverifiedUser_RelaunchStillBlockedFromMain`
- `Suite1.test12` -> `Registration_VerificationScreenLogout_ReturnsToLogin`
- `Suite2.test02` -> `Auth_ValidLogin_TransitionsToMainApp`
- `Suite2.test06` -> `Auth_Logout_FromMainApp_ReturnsToLogin`
- `Suite3.test01` -> `Residence_Precondition_AuthenticatedAndAtResidencesTab`
- `Suite3.test02` -> `Residence_OpenCreateForm`
- `Suite3.test03` -> `Residence_TabNavigation_MainSections`
- `Suite3.test04` -> `Residence_CancelCreate_ReturnsToList`
- `Suite3.test05` -> `Residence_CreateMinimal_ShowsInList`
- `Suite3.test06` -> `Residence_OpenDetails_FromList`

View File

@@ -0,0 +1,174 @@
# Suite1 Registration Failing Tests: Coverage + Rebuild Plan
## Scope
This document captures what the currently failing registration-flow tests are trying to validate and how to recreate that coverage using the new UI test architecture.
Source tests:
- `Suite1_RegistrationTests.test07_successfulRegistrationAndVerification`
- `Suite1_RegistrationTests.test09_registrationWithInvalidVerificationCode`
- `Suite1_RegistrationTests.test10_verificationCodeFieldValidation`
- `Suite1_RegistrationTests.test11_appRelaunchWithUnverifiedUser`
- `Suite1_RegistrationTests.test12_logoutFromVerificationScreen`
## Current Failure Context (Observed)
- Registration submit does not transition to a verification screen in automation runs.
- UI-level registration error shown during failures: `Password must be at least 8 characters`.
- Because registration transition fails, downstream verification assertions fail.
## What Each Failing Test Is Actually Testing
### 1) `test07_successfulRegistrationAndVerification`
Behavior intent:
- User can register with valid credentials.
- App transitions to verification state.
- Entering valid verification code completes verification.
- User lands in main app (tab bar available).
- Logout returns user to login.
Core business coverage:
- Happy-path onboarding/auth state progression.
- Verified user session gains app access.
- Logout clears authenticated session.
### 2) `test09_registrationWithInvalidVerificationCode`
Behavior intent:
- Registration reaches verification state.
- Entering wrong code shows verification error.
- User remains blocked from main app.
Core business coverage:
- Backend validation for invalid verification code.
- No false positive promotion to verified state.
### 3) `test10_verificationCodeFieldValidation`
Behavior intent:
- Verification screen enforces code format/length.
- Incomplete code does not complete verification.
- User remains on verification state.
Core business coverage:
- Client-side verification input guardrails.
- No bypass with partial code.
### 4) `test11_appRelaunchWithUnverifiedUser`
Behavior intent:
- User reaches unverified verification state.
- App terminate/relaunch preserves unverified gating.
- Relaunch must not allow direct main-app access.
Core business coverage:
- Session restore + auth gate correctness for unverified users.
### 5) `test12_logoutFromVerificationScreen`
Behavior intent:
- Unverified user can explicitly logout from verification screen.
- Verification UI dismisses.
- App returns to interactive login screen.
Core business coverage:
- Logout works from gated verification state.
- Session cleanup from pre-verified auth state.
## Rebuild These in New Architecture
## Shared Test Architecture Requirements
Create/ensure these reusable pieces:
- `AuthFlowHarness` (launch + auth preconditions + cleanup)
- `RegistrationScreen` page object
- `VerificationScreen` page object
- `MainTabScreen` page object
- `SessionStateAsserts` helpers for `login`, `verification`, `mainApp`
- `TestUserFactory` with deterministic unique users
Use stable selectors first:
- Accessibility IDs over title text.
- Support both auth/onboarding verification IDs only if product can route to either screen.
## Suggested New-Arch Test Cases (One-to-One Replacement)
### A. `Registration_HappyPath_CompletesVerification_ThenCanLogout`
Covers legacy test07.
Given:
- Fresh launch, logged out.
When:
- Register with valid user.
- Verify with valid code.
- Logout from profile/main app.
Then:
- Verification gate appears after register.
- Main app appears only after successful verify.
- Logout returns to login root.
### B. `Registration_InvalidVerifyCode_ShowsError_StaysUnverified`
Covers legacy test09.
Given:
- User registered and on verification screen.
When:
- Submit invalid verification code.
Then:
- Error banner/message visible.
- Verification screen remains active.
- Main app root not accessible.
### C. `Registration_IncompleteVerifyCode_DoesNotVerify`
Covers legacy test10.
Given:
- User on verification screen.
When:
- Enter fewer than required digits.
- Attempt verify (or assert button disabled).
Then:
- Verification completion does not occur.
- User remains blocked from main app.
### D. `Registration_UnverifiedUser_RelaunchStillBlockedFromMain`
Covers legacy test11.
Given:
- User registered but not verified.
When:
- Terminate and relaunch app.
Then:
- User is on verification gate (or login if session invalidated).
- User is never placed directly in main app state.
### E. `Registration_VerificationScreenLogout_ReturnsToLogin`
Covers legacy test12.
Given:
- User at verification gate.
When:
- Tap logout on verification screen.
Then:
- Verification state exits.
- Login root becomes active and interactive.
## Data + Environment Strategy for Rebuild
- Use API mode/environment that is stable for registration + verification in CI and local runs.
- Seed/fixture verification code contract must be explicit (example: fixed debug code).
- Generate unique username/email per test to avoid collisions.
- If keyboard autofill overlays are flaky, centralize handling in input helper (not per-test).
## Migration Notes
- Keep legacy tests disabled/removed only after each replacement test is green.
- Track replacement mapping in PR description:
- `old test -> new test`
- Preserve negative assertions ("must NOT access main app before verify").
## Open Risks To Resolve During Rebuild
- Registration password entry flakiness from iOS strong-password UI overlays.
- Potential mismatch between onboarding verification screen IDs and auth verification screen IDs.
- Environment-dependent backend behavior (local/dev) affecting registration transition.

View File

@@ -0,0 +1,120 @@
import Foundation
/// Reusable test data builders for UI tests.
///
/// Each fixture generates unique names using random numbers or UUIDs
/// to ensure test isolation and prevent cross-test interference.
enum TestFixtures {
// MARK: - Users
struct TestUser {
let firstName: String
let lastName: String
let email: String
let password: String
/// Standard test user with unique email.
static let standard = TestUser(
firstName: "Test",
lastName: "User",
email: "uitest_\(UUID().uuidString.prefix(8))@test.com",
password: "TestPassword123!"
)
/// Secondary test user for multi-user scenarios.
static let secondary = TestUser(
firstName: "Second",
lastName: "Tester",
email: "uitest2_\(UUID().uuidString.prefix(8))@test.com",
password: "TestPassword456!"
)
/// Pre-existing test user with known credentials (must exist on backend).
static let existing = TestUser(
firstName: "Test",
lastName: "User",
email: "testuser",
password: "TestPass123!"
)
}
// MARK: - Residences
struct TestResidence {
let name: String
let address: String
let type: String
static let house = TestResidence(
name: "Test House \(Int.random(in: 1000...9999))",
address: "123 Test St",
type: "House"
)
static let apartment = TestResidence(
name: "Test Apt \(Int.random(in: 1000...9999))",
address: "456 Mock Ave",
type: "Apartment"
)
}
// MARK: - Tasks
struct TestTask {
let title: String
let description: String
let priority: String
let category: String
static let basic = TestTask(
title: "Test Task \(Int.random(in: 1000...9999))",
description: "A test task",
priority: "Medium",
category: "Cleaning"
)
static let urgent = TestTask(
title: "Urgent Task \(Int.random(in: 1000...9999))",
description: "An urgent task",
priority: "High",
category: "Repair"
)
}
// MARK: - Documents
struct TestDocument {
let title: String
let description: String
let type: String
static let basic = TestDocument(
title: "Test Doc \(Int.random(in: 1000...9999))",
description: "A test document",
type: "Manual"
)
static let warranty = TestDocument(
title: "Test Warranty \(Int.random(in: 1000...9999))",
description: "A test warranty",
type: "Warranty"
)
}
// MARK: - Contractors
struct TestContractor {
let name: String
let phone: String
let email: String
let specialty: String
static let basic = TestContractor(
name: "Test Contractor \(Int.random(in: 1000...9999))",
phone: "555-0100",
email: "contractor@test.com",
specialty: "Plumber"
)
}
}

View File

@@ -0,0 +1,159 @@
import XCTest
/// Base class for tests requiring a logged-in session against the real local backend.
///
/// By default, creates a fresh verified account via the API, launches the app
/// (without `--ui-test-mock-auth`), and drives the UI through login.
///
/// Override `useSeededAccount` to log in with a pre-existing database account instead.
/// Override `performUILogin` to skip the UI login step (if you only need the API session).
///
/// ## Data Seeding & Cleanup
/// Use the `cleaner` property to seed data that auto-cleans in tearDown:
/// ```
/// let residence = cleaner.seedResidence(name: "My Test Home")
/// let task = cleaner.seedTask(residenceId: residence.id)
/// ```
/// Or seed without tracking via `TestDataSeeder` and track manually:
/// ```
/// let res = TestDataSeeder.createResidence(token: session.token)
/// cleaner.trackResidence(res.id)
/// ```
class AuthenticatedTestCase: BaseUITestCase {
/// The active test session, populated during setUp.
var session: TestSession!
/// Tracks and cleans up resources created during the test.
/// Initialized in setUp after the session is established.
private(set) var cleaner: TestDataCleaner!
/// Override to `true` in subclasses that should use the pre-seeded admin account.
var useSeededAccount: Bool { false }
/// Seeded account credentials. Override in subclasses that use a different seeded user.
var seededUsername: String { "admin" }
var seededPassword: String { "test1234" }
/// Override to `false` to skip driving the app through the login UI.
var performUILogin: Bool { true }
/// No mock auth - we're testing against the real backend.
override var additionalLaunchArguments: [String] { [] }
// MARK: - Setup
override func setUpWithError() throws {
// Check backend reachability before anything else
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
}
// Create or login account via API
if useSeededAccount {
guard let s = TestAccountManager.loginSeededAccount(
username: seededUsername,
password: seededPassword
) else {
throw XCTSkip("Could not login seeded account '\(seededUsername)'")
}
session = s
} else {
guard let s = TestAccountManager.createVerifiedAccount() else {
throw XCTSkip("Could not create verified test account")
}
session = s
}
// Initialize the cleaner with the session token
cleaner = TestDataCleaner(token: session.token)
// Launch the app (calls BaseUITestCase.setUpWithError which launches and waits for ready)
try super.setUpWithError()
// Drive the UI through login if needed
if performUILogin {
loginViaUI()
}
}
override func tearDownWithError() throws {
// Clean up all tracked test data
cleaner?.cleanAll()
try super.tearDownWithError()
}
// MARK: - UI Login
/// Navigate from onboarding welcome login screen type credentials wait for main tabs.
func loginViaUI() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.enterUsername(session.username)
login.enterPassword(session.password)
// Tap the login button
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
// Wait for either main tabs or verification screen
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
let tabBar = app.tabBars.firstMatch
let deadline = Date().addingTimeInterval(longTimeout)
while Date() < deadline {
if mainTabs.exists || tabBar.exists {
return
}
// Check for email verification gate - if we hit it, enter the debug code
let verificationScreen = VerificationScreen(app: app)
if verificationScreen.codeField.exists {
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
verificationScreen.submitCode()
// Wait for main tabs after verification
if mainTabs.waitForExistence(timeout: longTimeout) || tabBar.waitForExistence(timeout: 5) {
return
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
XCTFail("Failed to reach main app after login. Debug tree:\n\(app.debugDescription)")
}
// MARK: - Tab Navigation
func navigateToTab(_ tab: String) {
let tabButton = app.buttons[tab]
if tabButton.waitForExistence(timeout: defaultTimeout) {
tabButton.forceTap()
} else {
// Fallback: search tab bar buttons by label
let label = tab.replacingOccurrences(of: "TabBar.", with: "")
let byLabel = app.tabBars.buttons.containing(
NSPredicate(format: "label CONTAINS[c] %@", label)
).firstMatch
byLabel.waitForExistenceOrFail(timeout: defaultTimeout)
byLabel.forceTap()
}
}
func navigateToResidences() {
navigateToTab(AccessibilityIdentifiers.Navigation.residencesTab)
}
func navigateToTasks() {
navigateToTab(AccessibilityIdentifiers.Navigation.tasksTab)
}
func navigateToContractors() {
navigateToTab(AccessibilityIdentifiers.Navigation.contractorsTab)
}
func navigateToDocuments() {
navigateToTab(AccessibilityIdentifiers.Navigation.documentsTab)
}
func navigateToProfile() {
navigateToTab(AccessibilityIdentifiers.Navigation.profileTab)
}
}

View File

@@ -0,0 +1,124 @@
import XCTest
class BaseUITestCase: XCTestCase {
let app = XCUIApplication()
let shortTimeout: TimeInterval = 5
let defaultTimeout: TimeInterval = 15
let longTimeout: TimeInterval = 30
var includeResetStateLaunchArgument: Bool { true }
var additionalLaunchArguments: [String] { [] }
override func setUpWithError() throws {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
var launchArguments = [
"--ui-testing",
"--disable-animations"
]
if includeResetStateLaunchArgument {
launchArguments.append("--reset-state")
}
launchArguments.append(contentsOf: additionalLaunchArguments)
app.launchArguments = launchArguments
app.launch()
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: defaultTimeout)
}
override func tearDownWithError() throws {
if let run = testRun, !run.hasSucceeded {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Failure-\(name)"
attachment.lifetime = .keepAlways
add(attachment)
}
}
}
extension XCUIElement {
@discardableResult
func waitForExistenceOrFail(
timeout: TimeInterval,
message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> XCUIElement {
if !waitForExistence(timeout: timeout) {
XCTFail(message ?? "Expected element to exist: \(self)", file: file, line: line)
}
return self
}
@discardableResult
func waitUntilHittable(
timeout: TimeInterval,
message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> XCUIElement {
let predicate = NSPredicate(format: "exists == true AND hittable == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
if result != .completed {
XCTFail(message ?? "Expected element to become hittable: \(self)", file: file, line: line)
}
return self
}
@discardableResult
func waitForNonExistence(
timeout: TimeInterval,
message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
if result != .completed {
XCTFail(message ?? "Expected element to disappear: \(self)", file: file, line: line)
return false
}
return true
}
func scrollIntoView(
in scrollView: XCUIElement,
maxSwipes: Int = 8,
file: StaticString = #filePath,
line: UInt = #line
) {
if isHittable { return }
for _ in 0..<maxSwipes {
scrollView.swipeUp()
if isHittable { return }
}
for _ in 0..<maxSwipes {
scrollView.swipeDown()
if isHittable { return }
}
XCTFail("Failed to scroll element into view: \(self)", file: file, line: line)
}
func forceTap(file: StaticString = #filePath, line: UInt = #line) {
if isHittable {
tap()
return
}
if exists {
coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
return
}
XCTFail("Expected element to exist before forceTap: \(self)", file: file, line: line)
}
}

View File

@@ -0,0 +1,183 @@
import XCTest
struct RebuildTestUser {
let username: String
let email: String
let password: String
}
enum RebuildTestUserFactory {
static func unique(prefix: String = "uit") -> RebuildTestUser {
let stamp = Int(Date().timeIntervalSince1970)
return RebuildTestUser(
username: "\(prefix)_user_\(stamp)",
email: "\(prefix)_\(stamp)@example.com",
password: "Pass1234"
)
}
static var seeded: RebuildTestUser {
RebuildTestUser(username: "testuser", email: "test@example.com", password: "TestPass123!")
}
}
struct VerificationScreen {
let app: XCUIApplication
private var authCodeField: XCUIElement { app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] }
private var onboardingCodeField: XCUIElement { app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField] }
private var authVerifyButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Authentication.verifyButton] }
private var onboardingVerifyButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton] }
var codeField: XCUIElement {
if authCodeField.exists { return authCodeField }
return onboardingCodeField
}
var verifyButton: XCUIElement {
if authVerifyButton.exists { return authVerifyButton }
if onboardingVerifyButton.exists { return onboardingVerifyButton }
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
}
func waitForLoad(timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) {
let loaded = authCodeField.waitForExistence(timeout: timeout)
|| onboardingCodeField.waitForExistence(timeout: timeout)
|| authVerifyButton.waitForExistence(timeout: timeout)
|| onboardingVerifyButton.waitForExistence(timeout: timeout)
XCTAssertTrue(loaded, "Expected verification screen to load", file: file, line: line)
}
func enterCode(_ code: String) {
codeField.waitForExistenceOrFail(timeout: 10)
codeField.forceTap()
codeField.typeText(code)
}
func submitCode() {
verifyButton.waitForExistenceOrFail(timeout: 10)
verifyButton.forceTap()
}
func tapLogoutIfAvailable() {
let logout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
if logout.waitForExistence(timeout: 3) {
logout.forceTap()
}
}
}
struct MainTabScreenObject {
let app: XCUIApplication
var tabBar: XCUIElement { app.tabBars.firstMatch }
var mainRoot: XCUIElement { app.otherElements[UITestID.Root.mainTabs] }
var residencesTab: XCUIElement {
let byID = app.buttons[AccessibilityIdentifiers.Navigation.residencesTab]
if byID.exists { return byID }
return app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
}
var profileTab: XCUIElement {
let byID = app.buttons[AccessibilityIdentifiers.Navigation.profileTab]
if byID.exists { return byID }
return app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch
}
func waitForLoad(timeout: TimeInterval = 15) {
let loaded = mainRoot.waitForExistence(timeout: timeout)
|| tabBar.waitForExistence(timeout: timeout)
XCTAssertTrue(loaded, "Expected main app root to appear")
}
func goToResidences() {
residencesTab.waitForExistenceOrFail(timeout: 10)
residencesTab.forceTap()
}
func goToProfile() {
profileTab.waitForExistenceOrFail(timeout: 10)
profileTab.forceTap()
}
}
struct ResidenceListScreen {
let app: XCUIApplication
var addButton: XCUIElement {
let byID = app.buttons[AccessibilityIdentifiers.Residence.addButton]
if byID.exists { return byID }
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch
}
var list: XCUIElement { app.otherElements[AccessibilityIdentifiers.Residence.residencesList] }
var emptyState: XCUIElement { app.otherElements[AccessibilityIdentifiers.Residence.emptyStateView] }
var residenceCard: XCUIElement { app.otherElements.matching(identifier: AccessibilityIdentifiers.Residence.residenceCard).firstMatch }
func waitForLoad(timeout: TimeInterval = 15) {
let deadline = Date().addingTimeInterval(timeout)
var loaded = false
repeat {
loaded = list.exists
|| emptyState.exists
|| residenceCard.exists
|| addButton.exists
|| app.staticTexts["Residences"].exists
if loaded { break }
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
} while Date() < deadline
XCTAssertTrue(loaded, "Expected residences list screen to load")
}
func openCreateResidence() {
addButton.waitForExistenceOrFail(timeout: 10)
addButton.forceTap()
}
}
struct ResidenceFormScreen {
let app: XCUIApplication
var nameField: XCUIElement { app.textFields[AccessibilityIdentifiers.Residence.nameField] }
var saveButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Residence.saveButton] }
var cancelButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Residence.formCancelButton] }
func waitForLoad(timeout: TimeInterval = 15) {
XCTAssertTrue(nameField.waitForExistence(timeout: timeout), "Expected residence form")
}
func enterName(_ value: String) {
nameField.waitForExistenceOrFail(timeout: 10)
nameField.forceTap()
nameField.typeText(value)
}
func save() { saveButton.waitForExistenceOrFail(timeout: 10); saveButton.forceTap() }
func cancel() { cancelButton.waitForExistenceOrFail(timeout: 10); cancelButton.forceTap() }
}
enum RebuildSessionAssertions {
static func assertOnLogin(_ app: XCUIApplication, timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) {
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: timeout)
XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists, "Expected login state", file: file, line: line)
}
static func assertOnMainApp(_ app: XCUIApplication, timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) {
let main = MainTabScreenObject(app: app)
main.waitForLoad(timeout: timeout)
XCTAssertTrue(
app.otherElements[UITestID.Root.mainTabs].exists || main.tabBar.exists,
"Expected main app state",
file: file,
line: line
)
}
static func assertOnVerification(_ app: XCUIApplication, timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) {
let verify = VerificationScreen(app: app)
verify.waitForLoad(timeout: timeout, file: file, line: line)
}
}

View File

@@ -0,0 +1,411 @@
import XCTest
struct UITestID {
struct Root {
static let ready = "ui.app.ready"
static let onboarding = "ui.root.onboarding"
static let login = "ui.root.login"
static let mainTabs = "ui.root.mainTabs"
}
struct Onboarding {
static let welcomeTitle = "Onboarding.WelcomeTitle"
static let startFreshButton = "Onboarding.StartFreshButton"
static let joinExistingButton = "Onboarding.JoinExistingButton"
static let loginButton = "Onboarding.LoginButton"
static let valuePropsContainer = "Onboarding.ValuePropsTitle"
static let valuePropsNextButton = "Onboarding.ValuePropsNextButton"
static let nameResidenceTitle = "Onboarding.NameResidenceTitle"
static let residenceNameField = "Onboarding.ResidenceNameField"
static let nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton"
static let createAccountTitle = "Onboarding.CreateAccountTitle"
static let emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton"
static let createAccountButton = "Onboarding.CreateAccountButton"
static let backButton = "Onboarding.BackButton"
static let skipButton = "Onboarding.SkipButton"
static let progressIndicator = "Onboarding.ProgressIndicator"
}
struct PasswordReset {
static let emailField = "PasswordReset.EmailField"
static let sendCodeButton = "PasswordReset.SendCodeButton"
static let backToLoginButton = "PasswordReset.BackToLoginButton"
static let codeField = "PasswordReset.CodeField"
static let verifyCodeButton = "PasswordReset.VerifyCodeButton"
static let resendCodeButton = "PasswordReset.ResendCodeButton"
static let newPasswordField = "PasswordReset.NewPasswordField"
static let confirmPasswordField = "PasswordReset.ConfirmPasswordField"
static let resetButton = "PasswordReset.ResetButton"
static let returnToLoginButton = "PasswordReset.ReturnToLoginButton"
}
struct Auth {
static let usernameField = "Login.UsernameField"
static let passwordField = "Login.PasswordField"
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
static let loginButton = "Login.LoginButton"
static let signUpButton = "Login.SignUpButton"
static let forgotPasswordButton = "Login.ForgotPasswordButton"
static let registerUsernameField = "Register.UsernameField"
static let registerEmailField = "Register.EmailField"
static let registerPasswordField = "Register.PasswordField"
static let registerConfirmPasswordField = "Register.ConfirmPasswordField"
static let registerButton = "Register.RegisterButton"
static let registerCancelButton = "Register.CancelButton"
}
}
struct RootScreen {
let app: XCUIApplication
func waitForReady(timeout: TimeInterval = 15) {
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: timeout)
}
}
struct OnboardingWelcomeScreen {
let app: XCUIApplication
private var onboardingRoot: XCUIElement { app.otherElements[UITestID.Root.onboarding] }
private var startFreshButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.startFreshButton).firstMatch }
private var joinExistingButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.joinExistingButton).firstMatch }
private var loginButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.loginButton).firstMatch }
private var backButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch }
func waitForLoad(timeout: TimeInterval = 15) {
onboardingRoot.waitForExistenceOrFail(timeout: timeout)
if startFreshButton.waitForExistence(timeout: 2) {
return
}
for _ in 0..<4 {
if backButton.exists && backButton.isHittable {
backButton.tap()
}
if startFreshButton.waitForExistence(timeout: 2) {
return
}
}
if !startFreshButton.waitForExistence(timeout: timeout) {
XCTFail("Expected onboarding welcome entry point. Debug tree:\n\(app.debugDescription)")
}
}
func tapStartFresh() {
startFreshButton.waitUntilHittable(timeout: 10).tap()
}
func tapJoinExisting() {
joinExistingButton.waitUntilHittable(timeout: 10).tap()
}
func tapAlreadyHaveAccount() {
loginButton.waitForExistenceOrFail(timeout: 10)
if loginButton.isHittable {
loginButton.tap()
} else {
loginButton.forceTap()
}
}
}
struct OnboardingValuePropsScreen {
let app: XCUIApplication
private var container: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsContainer).firstMatch }
private var continueButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsNextButton).firstMatch }
private var backButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch }
func waitForLoad(timeout: TimeInterval = 15) {
container.waitForExistenceOrFail(timeout: timeout)
}
func tapContinue() {
continueButton.waitUntilHittable(timeout: 10).tap()
}
func tapBack() {
backButton.waitForExistenceOrFail(timeout: 10)
backButton.forceTap()
}
}
struct OnboardingNameResidenceScreen {
let app: XCUIApplication
private var title: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceTitle).firstMatch }
private var nameField: XCUIElement { app.textFields[UITestID.Onboarding.residenceNameField] }
private var continueButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceContinueButton).firstMatch }
private var backButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch }
func waitForLoad(timeout: TimeInterval = 15) {
title.waitForExistenceOrFail(timeout: timeout)
}
func enterResidenceName(_ value: String) {
nameField.waitUntilHittable(timeout: 10).tap()
nameField.typeText(value)
}
func tapContinue() {
continueButton.waitUntilHittable(timeout: 10).tap()
}
func tapBack() {
backButton.waitForExistenceOrFail(timeout: 10)
backButton.forceTap()
}
}
struct OnboardingCreateAccountScreen {
let app: XCUIApplication
private var title: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.createAccountTitle).firstMatch }
private var expandEmailButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.emailSignUpExpandButton).firstMatch }
private var createAccountButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.createAccountButton).firstMatch }
func waitForLoad(timeout: TimeInterval = 15) {
title.waitForExistenceOrFail(timeout: timeout)
}
func expandEmailSignup() {
expandEmailButton.waitUntilHittable(timeout: 10).tap()
}
func waitForCreateAccountButton(timeout: TimeInterval = 10) {
createAccountButton.waitForExistenceOrFail(timeout: timeout)
}
}
struct LoginScreenObject {
let app: XCUIApplication
private var usernameField: XCUIElement { app.textFields[UITestID.Auth.usernameField] }
private var passwordSecureField: XCUIElement { app.secureTextFields[UITestID.Auth.passwordField] }
private var passwordVisibleField: XCUIElement { app.textFields[UITestID.Auth.passwordField] }
private var loginButton: XCUIElement { app.buttons[UITestID.Auth.loginButton] }
private var signUpButton: XCUIElement { app.buttons[UITestID.Auth.signUpButton] }
private var forgotPasswordButton: XCUIElement { app.buttons[UITestID.Auth.forgotPasswordButton] }
private var visibilityToggle: XCUIElement { app.buttons[UITestID.Auth.passwordVisibilityToggle] }
func waitForLoad(timeout: TimeInterval = 15) {
usernameField.waitForExistenceOrFail(timeout: timeout)
loginButton.waitForExistenceOrFail(timeout: timeout)
}
func enterUsername(_ username: String) {
usernameField.waitUntilHittable(timeout: 10).tap()
usernameField.typeText(username)
}
func enterPassword(_ password: String) {
if passwordSecureField.exists {
passwordSecureField.tap()
passwordSecureField.typeText(password)
} else {
passwordVisibleField.waitUntilHittable(timeout: 10).tap()
passwordVisibleField.typeText(password)
}
}
func tapPasswordVisibilityToggle() {
visibilityToggle.waitUntilHittable(timeout: 10).tap()
}
func tapSignUp() {
signUpButton.waitUntilHittable(timeout: 10).tap()
}
func tapForgotPassword() {
forgotPasswordButton.waitUntilHittable(timeout: 10).tap()
}
func assertPasswordFieldVisible() {
XCTAssertTrue(passwordVisibleField.waitForExistence(timeout: 5), "Expected visible password text field after toggle")
}
}
struct RegisterScreenObject {
let app: XCUIApplication
private var usernameField: XCUIElement { app.textFields[UITestID.Auth.registerUsernameField] }
private var emailField: XCUIElement { app.textFields[UITestID.Auth.registerEmailField] }
private var passwordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerPasswordField] }
private var confirmPasswordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerConfirmPasswordField] }
private var registerButton: XCUIElement { app.buttons[UITestID.Auth.registerButton] }
private var cancelButton: XCUIElement { app.buttons[UITestID.Auth.registerCancelButton] }
func waitForLoad(timeout: TimeInterval = 15) {
usernameField.waitForExistenceOrFail(timeout: timeout)
registerButton.waitForExistenceOrFail(timeout: timeout)
}
func fill(username: String, email: String, password: String) {
func advanceToNextField() {
let keys = ["Next", "Return", "return", "Done", "done"]
for key in keys {
let button = app.keyboards.buttons[key]
if button.waitForExistence(timeout: 1) && button.isHittable {
button.tap()
return
}
}
}
usernameField.waitForExistenceOrFail(timeout: 10)
usernameField.forceTap()
usernameField.typeText(username)
advanceToNextField()
emailField.waitForExistenceOrFail(timeout: 10)
if !emailField.hasKeyboardFocus {
emailField.forceTap()
if !emailField.hasKeyboardFocus {
advanceToNextField()
emailField.forceTap()
}
}
emailField.typeText(email)
advanceToNextField()
passwordField.waitForExistenceOrFail(timeout: 10)
if !passwordField.hasKeyboardFocus {
passwordField.forceTap()
}
passwordField.typeText(password)
advanceToNextField()
confirmPasswordField.waitForExistenceOrFail(timeout: 10)
if !confirmPasswordField.hasKeyboardFocus {
confirmPasswordField.forceTap()
}
confirmPasswordField.typeText(password)
}
func tapCancel() {
cancelButton.waitUntilHittable(timeout: 10).tap()
}
}
// MARK: - Password Reset Screens
struct ForgotPasswordScreen {
let app: XCUIApplication
private var emailField: XCUIElement { app.textFields[UITestID.PasswordReset.emailField] }
private var sendCodeButton: XCUIElement { app.buttons[UITestID.PasswordReset.sendCodeButton] }
private var backToLoginButton: XCUIElement { app.buttons[UITestID.PasswordReset.backToLoginButton] }
func waitForLoad(timeout: TimeInterval = 15) {
// Wait for the email field or the "Forgot Password?" title
let emailLoaded = emailField.waitForExistence(timeout: timeout)
if !emailLoaded {
let title = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Forgot Password'")
).firstMatch
XCTAssertTrue(title.waitForExistence(timeout: 5), "Expected forgot password screen to load")
}
}
func enterEmail(_ email: String) {
emailField.waitUntilHittable(timeout: 10).tap()
emailField.typeText(email)
}
func tapSendCode() {
sendCodeButton.waitUntilHittable(timeout: 10).tap()
}
func tapBackToLogin() {
backToLoginButton.waitUntilHittable(timeout: 10).tap()
}
}
struct VerifyResetCodeScreen {
let app: XCUIApplication
private var codeField: XCUIElement { app.textFields[UITestID.PasswordReset.codeField] }
private var verifyCodeButton: XCUIElement { app.buttons[UITestID.PasswordReset.verifyCodeButton] }
private var resendCodeButton: XCUIElement { app.buttons[UITestID.PasswordReset.resendCodeButton] }
func waitForLoad(timeout: TimeInterval = 15) {
let codeLoaded = codeField.waitForExistence(timeout: timeout)
if !codeLoaded {
let title = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Check Your Email'")
).firstMatch
XCTAssertTrue(title.waitForExistence(timeout: 5), "Expected verify reset code screen to load")
}
}
func enterCode(_ code: String) {
codeField.waitUntilHittable(timeout: 10).tap()
codeField.typeText(code)
}
func tapVerify() {
verifyCodeButton.waitUntilHittable(timeout: 10).tap()
}
func tapResendCode() {
resendCodeButton.waitUntilHittable(timeout: 10).tap()
}
}
struct ResetPasswordScreen {
let app: XCUIApplication
// The new password field may be a SecureField or TextField depending on visibility toggle
private var newPasswordSecureField: XCUIElement { app.secureTextFields[UITestID.PasswordReset.newPasswordField] }
private var newPasswordVisibleField: XCUIElement { app.textFields[UITestID.PasswordReset.newPasswordField] }
private var confirmPasswordSecureField: XCUIElement { app.secureTextFields[UITestID.PasswordReset.confirmPasswordField] }
private var confirmPasswordVisibleField: XCUIElement { app.textFields[UITestID.PasswordReset.confirmPasswordField] }
private var resetButton: XCUIElement { app.buttons[UITestID.PasswordReset.resetButton] }
private var returnToLoginButton: XCUIElement { app.buttons[UITestID.PasswordReset.returnToLoginButton] }
func waitForLoad(timeout: TimeInterval = 15) {
let loaded = newPasswordSecureField.waitForExistence(timeout: timeout)
|| newPasswordVisibleField.waitForExistence(timeout: 3)
if !loaded {
let title = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Set New Password'")
).firstMatch
XCTAssertTrue(title.waitForExistence(timeout: 5), "Expected reset password screen to load")
}
}
func enterNewPassword(_ password: String) {
if newPasswordSecureField.exists {
newPasswordSecureField.waitUntilHittable(timeout: 10).tap()
newPasswordSecureField.typeText(password)
} else {
newPasswordVisibleField.waitUntilHittable(timeout: 10).tap()
newPasswordVisibleField.typeText(password)
}
}
func enterConfirmPassword(_ password: String) {
if confirmPasswordSecureField.exists {
confirmPasswordSecureField.waitUntilHittable(timeout: 10).tap()
confirmPasswordSecureField.typeText(password)
} else {
confirmPasswordVisibleField.waitUntilHittable(timeout: 10).tap()
confirmPasswordVisibleField.typeText(password)
}
}
func tapReset() {
resetButton.waitUntilHittable(timeout: 10).tap()
}
func tapReturnToLogin() {
returnToLoginButton.waitUntilHittable(timeout: 10).tap()
}
var isResetButtonEnabled: Bool {
resetButton.waitForExistenceOrFail(timeout: 10)
return resetButton.isEnabled
}
}

View File

@@ -0,0 +1,505 @@
import Foundation
import XCTest
// MARK: - API Result Type
/// Result of an API call with status code access for error assertions.
struct APIResult<T> {
let data: T?
let statusCode: Int
let errorBody: String?
var succeeded: Bool { (200...299).contains(statusCode) }
/// Unwrap data or fail the test.
func unwrap(file: StaticString = #filePath, line: UInt = #line) -> T {
guard let data = data else {
XCTFail("Expected data but got status \(statusCode): \(errorBody ?? "nil")", file: file, line: line)
preconditionFailure("unwrap failed")
}
return data
}
}
// MARK: - Auth Response Types
struct TestUser: Decodable {
let id: Int
let username: String
let email: String
let firstName: String?
let lastName: String?
let isActive: Bool?
let verified: Bool?
enum CodingKeys: String, CodingKey {
case id, username, email
case firstName = "first_name"
case lastName = "last_name"
case isActive = "is_active"
case verified
}
}
struct TestAuthResponse: Decodable {
let token: String
let user: TestUser
let message: String?
}
struct TestVerifyEmailResponse: Decodable {
let message: String
let verified: Bool
}
struct TestVerifyResetCodeResponse: Decodable {
let message: String
let resetToken: String
enum CodingKeys: String, CodingKey {
case message
case resetToken = "reset_token"
}
}
struct TestMessageResponse: Decodable {
let message: String
}
struct TestSession {
let token: String
let user: TestUser
let username: String
let password: String
}
// MARK: - CRUD Response Types
/// Wrapper for create/update/get responses that include a summary.
struct TestWrappedResponse<T: Decodable>: Decodable {
let data: T
}
struct TestResidence: Decodable {
let id: Int
let name: String
let ownerId: Int?
let streetAddress: String?
let city: String?
let stateProvince: String?
let postalCode: String?
let isPrimary: Bool?
let isActive: Bool?
enum CodingKeys: String, CodingKey {
case id, name
case ownerId = "owner_id"
case streetAddress = "street_address"
case city
case stateProvince = "state_province"
case postalCode = "postal_code"
case isPrimary = "is_primary"
case isActive = "is_active"
}
}
struct TestTask: Decodable {
let id: Int
let residenceId: Int
let title: String
let description: String?
let inProgress: Bool?
let isCancelled: Bool?
let isArchived: Bool?
let kanbanColumn: String?
enum CodingKeys: String, CodingKey {
case id, title, description
case residenceId = "residence_id"
case inProgress = "in_progress"
case isCancelled = "is_cancelled"
case isArchived = "is_archived"
case kanbanColumn = "kanban_column"
}
}
struct TestContractor: Decodable {
let id: Int
let name: String
let company: String?
let phone: String?
let email: String?
let isFavorite: Bool?
let isActive: Bool?
enum CodingKeys: String, CodingKey {
case id, name, company, phone, email
case isFavorite = "is_favorite"
case isActive = "is_active"
}
}
struct TestDocument: Decodable {
let id: Int
let residenceId: Int
let title: String
let documentType: String?
let isActive: Bool?
enum CodingKeys: String, CodingKey {
case id, title
case residenceId = "residence_id"
case documentType = "document_type"
case isActive = "is_active"
}
}
// MARK: - API Client
enum TestAccountAPIClient {
static let baseURL = "http://127.0.0.1:8000/api"
static let debugVerificationCode = "123456"
// MARK: - Auth Methods
static func register(username: String, email: String, password: String) -> TestAuthResponse? {
let body: [String: Any] = [
"username": username,
"email": email,
"password": password
]
return performRequest(method: "POST", path: "/auth/register/", body: body, responseType: TestAuthResponse.self)
}
static func login(username: String, password: String) -> TestAuthResponse? {
let body: [String: Any] = ["username": username, "password": password]
return performRequest(method: "POST", path: "/auth/login/", body: body, responseType: TestAuthResponse.self)
}
static func verifyEmail(token: String) -> TestVerifyEmailResponse? {
let body: [String: Any] = ["code": debugVerificationCode]
return performRequest(method: "POST", path: "/auth/verify-email/", body: body, token: token, responseType: TestVerifyEmailResponse.self)
}
static func getCurrentUser(token: String) -> TestUser? {
return performRequest(method: "GET", path: "/auth/me/", token: token, responseType: TestUser.self)
}
static func forgotPassword(email: String) -> TestMessageResponse? {
let body: [String: Any] = ["email": email]
return performRequest(method: "POST", path: "/auth/forgot-password/", body: body, responseType: TestMessageResponse.self)
}
static func verifyResetCode(email: String) -> TestVerifyResetCodeResponse? {
let body: [String: Any] = ["email": email, "code": debugVerificationCode]
return performRequest(method: "POST", path: "/auth/verify-reset-code/", body: body, responseType: TestVerifyResetCodeResponse.self)
}
static func resetPassword(resetToken: String, newPassword: String) -> TestMessageResponse? {
let body: [String: Any] = ["reset_token": resetToken, "new_password": newPassword]
return performRequest(method: "POST", path: "/auth/reset-password/", body: body, responseType: TestMessageResponse.self)
}
static func logout(token: String) -> TestMessageResponse? {
return performRequest(method: "POST", path: "/auth/logout/", token: token, responseType: TestMessageResponse.self)
}
/// Convenience: register + verify + re-login, returns ready session.
static func createVerifiedAccount(username: String, email: String, password: String) -> TestSession? {
guard let registerResponse = register(username: username, email: email, password: password) else { return nil }
guard verifyEmail(token: registerResponse.token) != nil else { return nil }
guard let loginResponse = login(username: username, password: password) else { return nil }
return TestSession(token: loginResponse.token, user: loginResponse.user, username: username, password: password)
}
// MARK: - Auth with Status Code
/// Login returning full APIResult so callers can assert on 401, 400, etc.
static func loginWithResult(username: String, password: String) -> APIResult<TestAuthResponse> {
let body: [String: Any] = ["username": username, "password": password]
return performRequestWithResult(method: "POST", path: "/auth/login/", body: body, responseType: TestAuthResponse.self)
}
/// Hit a protected endpoint without a token to get the 401.
static func getCurrentUserWithResult(token: String?) -> APIResult<TestUser> {
return performRequestWithResult(method: "GET", path: "/auth/me/", token: token, responseType: TestUser.self)
}
// MARK: - Residence CRUD
static func createResidence(token: String, name: String, fields: [String: Any] = [:]) -> TestResidence? {
var body: [String: Any] = ["name": name]
for (k, v) in fields { body[k] = v }
let wrapped: TestWrappedResponse<TestResidence>? = performRequest(
method: "POST", path: "/residences/", body: body, token: token,
responseType: TestWrappedResponse<TestResidence>.self
)
return wrapped?.data
}
static func listResidences(token: String) -> [TestResidence]? {
return performRequest(method: "GET", path: "/residences/", token: token, responseType: [TestResidence].self)
}
static func updateResidence(token: String, id: Int, fields: [String: Any]) -> TestResidence? {
let wrapped: TestWrappedResponse<TestResidence>? = performRequest(
method: "PUT", path: "/residences/\(id)/", body: fields, token: token,
responseType: TestWrappedResponse<TestResidence>.self
)
return wrapped?.data
}
static func deleteResidence(token: String, id: Int) -> Bool {
let result: APIResult<TestWrappedResponse<String>> = performRequestWithResult(
method: "DELETE", path: "/residences/\(id)/", token: token,
responseType: TestWrappedResponse<String>.self
)
return result.succeeded
}
// MARK: - Task CRUD
static func createTask(token: String, residenceId: Int, title: String, fields: [String: Any] = [:]) -> TestTask? {
var body: [String: Any] = ["residence_id": residenceId, "title": title]
for (k, v) in fields { body[k] = v }
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
method: "POST", path: "/tasks/", body: body, token: token,
responseType: TestWrappedResponse<TestTask>.self
)
return wrapped?.data
}
static func listTasks(token: String) -> [TestTask]? {
return performRequest(method: "GET", path: "/tasks/", token: token, responseType: [TestTask].self)
}
static func listTasksByResidence(token: String, residenceId: Int) -> [TestTask]? {
return performRequest(
method: "GET", path: "/tasks/by-residence/\(residenceId)/", token: token,
responseType: [TestTask].self
)
}
static func updateTask(token: String, id: Int, fields: [String: Any]) -> TestTask? {
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
method: "PUT", path: "/tasks/\(id)/", body: fields, token: token,
responseType: TestWrappedResponse<TestTask>.self
)
return wrapped?.data
}
static func deleteTask(token: String, id: Int) -> Bool {
let result: APIResult<TestWrappedResponse<String>> = performRequestWithResult(
method: "DELETE", path: "/tasks/\(id)/", token: token,
responseType: TestWrappedResponse<String>.self
)
return result.succeeded
}
static func markTaskInProgress(token: String, id: Int) -> TestTask? {
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
method: "POST", path: "/tasks/\(id)/mark-in-progress/", token: token,
responseType: TestWrappedResponse<TestTask>.self
)
return wrapped?.data
}
static func cancelTask(token: String, id: Int) -> TestTask? {
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
method: "POST", path: "/tasks/\(id)/cancel/", token: token,
responseType: TestWrappedResponse<TestTask>.self
)
return wrapped?.data
}
static func uncancelTask(token: String, id: Int) -> TestTask? {
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
method: "POST", path: "/tasks/\(id)/uncancel/", token: token,
responseType: TestWrappedResponse<TestTask>.self
)
return wrapped?.data
}
// MARK: - Contractor CRUD
static func createContractor(token: String, name: String, fields: [String: Any] = [:]) -> TestContractor? {
var body: [String: Any] = ["name": name]
for (k, v) in fields { body[k] = v }
return performRequest(method: "POST", path: "/contractors/", body: body, token: token, responseType: TestContractor.self)
}
static func listContractors(token: String) -> [TestContractor]? {
return performRequest(method: "GET", path: "/contractors/", token: token, responseType: [TestContractor].self)
}
static func updateContractor(token: String, id: Int, fields: [String: Any]) -> TestContractor? {
return performRequest(method: "PUT", path: "/contractors/\(id)/", body: fields, token: token, responseType: TestContractor.self)
}
static func deleteContractor(token: String, id: Int) -> Bool {
let result: APIResult<TestMessageResponse> = performRequestWithResult(
method: "DELETE", path: "/contractors/\(id)/", token: token,
responseType: TestMessageResponse.self
)
return result.succeeded
}
static func toggleContractorFavorite(token: String, id: Int) -> TestContractor? {
return performRequest(method: "POST", path: "/contractors/\(id)/toggle-favorite/", token: token, responseType: TestContractor.self)
}
// MARK: - Document CRUD
static func createDocument(token: String, residenceId: Int, title: String, documentType: String = "Other", fields: [String: Any] = [:]) -> TestDocument? {
var body: [String: Any] = ["residence_id": residenceId, "title": title, "document_type": documentType]
for (k, v) in fields { body[k] = v }
return performRequest(method: "POST", path: "/documents/", body: body, token: token, responseType: TestDocument.self)
}
static func listDocuments(token: String) -> [TestDocument]? {
return performRequest(method: "GET", path: "/documents/", token: token, responseType: [TestDocument].self)
}
static func updateDocument(token: String, id: Int, fields: [String: Any]) -> TestDocument? {
return performRequest(method: "PUT", path: "/documents/\(id)/", body: fields, token: token, responseType: TestDocument.self)
}
static func deleteDocument(token: String, id: Int) -> Bool {
let result: APIResult<TestMessageResponse> = performRequestWithResult(
method: "DELETE", path: "/documents/\(id)/", token: token,
responseType: TestMessageResponse.self
)
return result.succeeded
}
// MARK: - Raw Request (for custom/edge-case assertions)
/// Make a raw request and return the full APIResult with status code.
static func rawRequest(method: String, path: String, body: [String: Any]? = nil, token: String? = nil) -> APIResult<Data> {
guard let url = URL(string: "\(baseURL)\(path)") else {
return APIResult(data: nil, statusCode: 0, errorBody: "Invalid URL")
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
if let token = token {
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let semaphore = DispatchSemaphore(value: 0)
var result = APIResult<Data>(data: nil, statusCode: 0, errorBody: "No response")
let task = URLSession.shared.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error = error {
result = APIResult(data: nil, statusCode: 0, errorBody: error.localizedDescription)
return
}
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let bodyStr = data.flatMap { String(data: $0, encoding: .utf8) }
if (200...299).contains(status) {
result = APIResult(data: data, statusCode: status, errorBody: nil)
} else {
result = APIResult(data: nil, statusCode: status, errorBody: bodyStr)
}
}
task.resume()
semaphore.wait()
return result
}
// MARK: - Reachability
static func isBackendReachable() -> Bool {
let result = rawRequest(method: "POST", path: "/auth/login/", body: [:])
// Any HTTP response (even 400) means the backend is up
return result.statusCode > 0
}
// MARK: - Private Core
/// Perform a request and return the decoded value, or nil on failure (logs errors).
private static func performRequest<T: Decodable>(
method: String,
path: String,
body: [String: Any]? = nil,
token: String? = nil,
responseType: T.Type
) -> T? {
let result = performRequestWithResult(method: method, path: path, body: body, token: token, responseType: responseType)
return result.data
}
/// Perform a request and return the full APIResult with status code.
static func performRequestWithResult<T: Decodable>(
method: String,
path: String,
body: [String: Any]? = nil,
token: String? = nil,
responseType: T.Type
) -> APIResult<T> {
guard let url = URL(string: "\(baseURL)\(path)") else {
return APIResult(data: nil, statusCode: 0, errorBody: "Invalid URL: \(baseURL)\(path)")
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
if let token = token {
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let semaphore = DispatchSemaphore(value: 0)
var result = APIResult<T>(data: nil, statusCode: 0, errorBody: "No response")
let task = URLSession.shared.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error = error {
print("[TestAPI] \(method) \(path) error: \(error.localizedDescription)")
result = APIResult(data: nil, statusCode: 0, errorBody: error.localizedDescription)
return
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard let data = data else {
print("[TestAPI] \(method) \(path) no data (status \(statusCode))")
result = APIResult(data: nil, statusCode: statusCode, errorBody: "No data")
return
}
let bodyStr = String(data: data, encoding: .utf8) ?? "<binary>"
guard (200...299).contains(statusCode) else {
print("[TestAPI] \(method) \(path) status \(statusCode): \(bodyStr)")
result = APIResult(data: nil, statusCode: statusCode, errorBody: bodyStr)
return
}
do {
let decoded = try JSONDecoder().decode(T.self, from: data)
result = APIResult(data: decoded, statusCode: statusCode, errorBody: nil)
} catch {
print("[TestAPI] \(method) \(path) decode error: \(error)\nBody: \(bodyStr)")
result = APIResult(data: nil, statusCode: statusCode, errorBody: "Decode error: \(error)")
}
}
task.resume()
semaphore.wait()
return result
}
}

View File

@@ -0,0 +1,127 @@
import Foundation
import XCTest
/// High-level account lifecycle management for UI tests.
enum TestAccountManager {
// MARK: - Credential Generation
/// Generate unique credentials with a timestamp + random suffix to avoid collisions.
static func uniqueCredentials(prefix: String = "uit") -> (username: String, email: String, password: String) {
let stamp = Int(Date().timeIntervalSince1970)
let random = Int.random(in: 1000...9999)
let username = "\(prefix)_\(stamp)_\(random)"
let email = "\(username)@test.example.com"
let password = "Pass\(stamp)!"
return (username, email, password)
}
// MARK: - Account Creation
/// Create a verified account via the backend API. Returns a ready-to-use session.
/// Calls `XCTFail` and returns nil if any step fails.
static func createVerifiedAccount(
file: StaticString = #filePath,
line: UInt = #line
) -> TestSession? {
let creds = uniqueCredentials()
guard let session = TestAccountAPIClient.createVerifiedAccount(
username: creds.username,
email: creds.email,
password: creds.password
) else {
XCTFail("Failed to create verified account for \(creds.username)", file: file, line: line)
return nil
}
return session
}
/// Create an unverified account (register only, no email verification).
/// Useful for testing the verification gate.
static func createUnverifiedAccount(
file: StaticString = #filePath,
line: UInt = #line
) -> TestSession? {
let creds = uniqueCredentials()
guard let response = TestAccountAPIClient.register(
username: creds.username,
email: creds.email,
password: creds.password
) else {
XCTFail("Failed to register unverified account for \(creds.username)", file: file, line: line)
return nil
}
return TestSession(
token: response.token,
user: response.user,
username: creds.username,
password: creds.password
)
}
// MARK: - Seeded Accounts
/// Login with a pre-seeded account that already exists in the database.
static func loginSeededAccount(
username: String = "admin",
password: String = "test1234",
file: StaticString = #filePath,
line: UInt = #line
) -> TestSession? {
guard let response = TestAccountAPIClient.login(username: username, password: password) else {
XCTFail("Failed to login seeded account '\(username)'", file: file, line: line)
return nil
}
return TestSession(
token: response.token,
user: response.user,
username: username,
password: password
)
}
// MARK: - Password Reset
/// Execute the full forgotverifyreset cycle via the backend API.
static func resetPassword(
email: String,
newPassword: String,
file: StaticString = #filePath,
line: UInt = #line
) -> Bool {
guard TestAccountAPIClient.forgotPassword(email: email) != nil else {
XCTFail("Forgot password request failed for \(email)", file: file, line: line)
return false
}
guard let verifyResponse = TestAccountAPIClient.verifyResetCode(email: email) else {
XCTFail("Verify reset code failed for \(email)", file: file, line: line)
return false
}
guard TestAccountAPIClient.resetPassword(resetToken: verifyResponse.resetToken, newPassword: newPassword) != nil else {
XCTFail("Reset password failed for \(email)", file: file, line: line)
return false
}
return true
}
// MARK: - Token Management
/// Invalidate a session token via the logout API.
static func invalidateToken(
_ session: TestSession,
file: StaticString = #filePath,
line: UInt = #line
) {
if TestAccountAPIClient.logout(token: session.token) == nil {
XCTFail("Failed to invalidate token for \(session.username)", file: file, line: line)
}
}
}

View File

@@ -0,0 +1,130 @@
import Foundation
import XCTest
/// Tracks and cleans up resources created during integration tests.
///
/// Usage:
/// ```
/// let cleaner = TestDataCleaner(token: session.token)
/// let residence = TestDataSeeder.createResidence(token: session.token)
/// cleaner.trackResidence(residence.id)
/// // ... test runs ...
/// cleaner.cleanAll() // called in tearDown
/// ```
class TestDataCleaner {
private let token: String
private var residenceIds: [Int] = []
private var taskIds: [Int] = []
private var contractorIds: [Int] = []
private var documentIds: [Int] = []
init(token: String) {
self.token = token
}
// MARK: - Track Resources
func trackResidence(_ id: Int) {
residenceIds.append(id)
}
func trackTask(_ id: Int) {
taskIds.append(id)
}
func trackContractor(_ id: Int) {
contractorIds.append(id)
}
func trackDocument(_ id: Int) {
documentIds.append(id)
}
// MARK: - Seed + Track (Convenience)
/// Create a residence and automatically track it for cleanup.
@discardableResult
func seedResidence(name: String? = nil) -> TestResidence {
let residence = TestDataSeeder.createResidence(token: token, name: name)
trackResidence(residence.id)
return residence
}
/// Create a task and automatically track it for cleanup.
@discardableResult
func seedTask(residenceId: Int, title: String? = nil, fields: [String: Any] = [:]) -> TestTask {
let task = TestDataSeeder.createTask(token: token, residenceId: residenceId, title: title, fields: fields)
trackTask(task.id)
return task
}
/// Create a contractor and automatically track it for cleanup.
@discardableResult
func seedContractor(name: String? = nil, fields: [String: Any] = [:]) -> TestContractor {
let contractor = TestDataSeeder.createContractor(token: token, name: name, fields: fields)
trackContractor(contractor.id)
return contractor
}
/// Create a document and automatically track it for cleanup.
@discardableResult
func seedDocument(residenceId: Int, title: String? = nil, documentType: String = "Other") -> TestDocument {
let document = TestDataSeeder.createDocument(token: token, residenceId: residenceId, title: title, documentType: documentType)
trackDocument(document.id)
return document
}
/// Create a residence with tasks, all tracked for cleanup.
func seedResidenceWithTasks(residenceName: String? = nil, taskCount: Int = 3) -> (residence: TestResidence, tasks: [TestTask]) {
let result = TestDataSeeder.createResidenceWithTasks(token: token, residenceName: residenceName, taskCount: taskCount)
trackResidence(result.residence.id)
result.tasks.forEach { trackTask($0.id) }
return result
}
/// Create a full residence with task, contractor, and document, all tracked.
func seedFullResidence() -> (residence: TestResidence, task: TestTask, contractor: TestContractor, document: TestDocument) {
let result = TestDataSeeder.createFullResidence(token: token)
trackResidence(result.residence.id)
trackTask(result.task.id)
trackContractor(result.contractor.id)
trackDocument(result.document.id)
return result
}
// MARK: - Cleanup
/// Delete all tracked resources in reverse dependency order.
/// Documents and tasks first (they depend on residences), then contractors, then residences.
/// Failures are logged but don't fail the test cleanup is best-effort.
func cleanAll() {
// Delete documents first (depend on residences)
for id in documentIds.reversed() {
_ = TestAccountAPIClient.deleteDocument(token: token, id: id)
}
documentIds.removeAll()
// Delete tasks (depend on residences)
for id in taskIds.reversed() {
_ = TestAccountAPIClient.deleteTask(token: token, id: id)
}
taskIds.removeAll()
// Delete contractors (independent, but clean before residences)
for id in contractorIds.reversed() {
_ = TestAccountAPIClient.deleteContractor(token: token, id: id)
}
contractorIds.removeAll()
// Delete residences last
for id in residenceIds.reversed() {
_ = TestAccountAPIClient.deleteResidence(token: token, id: id)
}
residenceIds.removeAll()
}
/// Number of tracked resources (for debugging).
var trackedCount: Int {
residenceIds.count + taskIds.count + contractorIds.count + documentIds.count
}
}

View File

@@ -0,0 +1,235 @@
import Foundation
import XCTest
/// Seeds backend data for integration tests via API calls.
///
/// All methods require a valid auth token from a `TestSession`.
/// Created resources are tracked so `TestDataCleaner` can remove them in teardown.
enum TestDataSeeder {
// MARK: - Residence Seeding
/// Create a residence with just a name. Returns the residence or fails the test.
@discardableResult
static func createResidence(
token: String,
name: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> TestResidence {
let residenceName = name ?? "Test Residence \(uniqueSuffix())"
guard let residence = TestAccountAPIClient.createResidence(token: token, name: residenceName) else {
XCTFail("Failed to seed residence '\(residenceName)'", file: file, line: line)
preconditionFailure("seeding failed")
}
return residence
}
/// Create a residence with address fields populated.
@discardableResult
static func createResidenceWithAddress(
token: String,
name: String? = nil,
street: String = "123 Test St",
city: String = "Testville",
state: String = "TX",
postalCode: String = "78701",
file: StaticString = #filePath,
line: UInt = #line
) -> TestResidence {
let residenceName = name ?? "Addressed Residence \(uniqueSuffix())"
guard let residence = TestAccountAPIClient.createResidence(
token: token,
name: residenceName,
fields: [
"street_address": street,
"city": city,
"state_province": state,
"postal_code": postalCode
]
) else {
XCTFail("Failed to seed residence with address '\(residenceName)'", file: file, line: line)
preconditionFailure("seeding failed")
}
return residence
}
// MARK: - Task Seeding
/// Create a task in a residence. Returns the task or fails the test.
@discardableResult
static func createTask(
token: String,
residenceId: Int,
title: String? = nil,
fields: [String: Any] = [:],
file: StaticString = #filePath,
line: UInt = #line
) -> TestTask {
let taskTitle = title ?? "Test Task \(uniqueSuffix())"
guard let task = TestAccountAPIClient.createTask(
token: token,
residenceId: residenceId,
title: taskTitle,
fields: fields
) else {
XCTFail("Failed to seed task '\(taskTitle)'", file: file, line: line)
preconditionFailure("seeding failed")
}
return task
}
/// Create a task with a due date.
@discardableResult
static func createTaskWithDueDate(
token: String,
residenceId: Int,
title: String? = nil,
daysFromNow: Int = 7,
file: StaticString = #filePath,
line: UInt = #line
) -> TestTask {
let dueDate = Calendar.current.date(byAdding: .day, value: daysFromNow, to: Date())!
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withFullDate]
let dueDateStr = formatter.string(from: dueDate)
return createTask(
token: token,
residenceId: residenceId,
title: title ?? "Due Task \(uniqueSuffix())",
fields: ["due_date": dueDateStr],
file: file,
line: line
)
}
/// Create a cancelled task (create then cancel via API).
@discardableResult
static func createCancelledTask(
token: String,
residenceId: Int,
title: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> TestTask {
let task = createTask(token: token, residenceId: residenceId, title: title ?? "Cancelled Task \(uniqueSuffix())", file: file, line: line)
guard let cancelled = TestAccountAPIClient.cancelTask(token: token, id: task.id) else {
XCTFail("Failed to cancel seeded task \(task.id)", file: file, line: line)
preconditionFailure("seeding failed")
}
return cancelled
}
// MARK: - Contractor Seeding
/// Create a contractor. Returns the contractor or fails the test.
@discardableResult
static func createContractor(
token: String,
name: String? = nil,
fields: [String: Any] = [:],
file: StaticString = #filePath,
line: UInt = #line
) -> TestContractor {
let contractorName = name ?? "Test Contractor \(uniqueSuffix())"
guard let contractor = TestAccountAPIClient.createContractor(
token: token,
name: contractorName,
fields: fields
) else {
XCTFail("Failed to seed contractor '\(contractorName)'", file: file, line: line)
preconditionFailure("seeding failed")
}
return contractor
}
/// Create a contractor with contact info.
@discardableResult
static func createContractorWithContact(
token: String,
name: String? = nil,
company: String = "Test Co",
phone: String = "555-0100",
email: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> TestContractor {
let contractorName = name ?? "Contact Contractor \(uniqueSuffix())"
let contactEmail = email ?? "\(uniqueSuffix())@contractor.test"
return createContractor(
token: token,
name: contractorName,
fields: ["company": company, "phone": phone, "email": contactEmail],
file: file,
line: line
)
}
// MARK: - Document Seeding
/// Create a document in a residence. Returns the document or fails the test.
@discardableResult
static func createDocument(
token: String,
residenceId: Int,
title: String? = nil,
documentType: String = "Other",
fields: [String: Any] = [:],
file: StaticString = #filePath,
line: UInt = #line
) -> TestDocument {
let docTitle = title ?? "Test Doc \(uniqueSuffix())"
guard let document = TestAccountAPIClient.createDocument(
token: token,
residenceId: residenceId,
title: docTitle,
documentType: documentType,
fields: fields
) else {
XCTFail("Failed to seed document '\(docTitle)'", file: file, line: line)
preconditionFailure("seeding failed")
}
return document
}
// MARK: - Composite Scenarios
/// Create a residence with N tasks already in it. Returns (residence, [tasks]).
static func createResidenceWithTasks(
token: String,
residenceName: String? = nil,
taskCount: Int = 3,
file: StaticString = #filePath,
line: UInt = #line
) -> (residence: TestResidence, tasks: [TestTask]) {
let residence = createResidence(token: token, name: residenceName, file: file, line: line)
var tasks: [TestTask] = []
for i in 1...taskCount {
let task = createTask(token: token, residenceId: residence.id, title: "Task \(i) \(uniqueSuffix())", file: file, line: line)
tasks.append(task)
}
return (residence, tasks)
}
/// Create a residence with a contractor and a document. Returns all three.
static func createFullResidence(
token: String,
file: StaticString = #filePath,
line: UInt = #line
) -> (residence: TestResidence, task: TestTask, contractor: TestContractor, document: TestDocument) {
let residence = createResidence(token: token, file: file, line: line)
let task = createTask(token: token, residenceId: residence.id, file: file, line: line)
let contractor = createContractor(token: token, file: file, line: line)
let document = createDocument(token: token, residenceId: residence.id, file: file, line: line)
return (residence, task, contractor, document)
}
// MARK: - Private
private static func uniqueSuffix() -> String {
let stamp = Int(Date().timeIntervalSince1970) % 100000
let random = Int.random(in: 100...999)
return "\(stamp)_\(random)"
}
}

View File

@@ -0,0 +1,95 @@
import XCTest
enum TestFlows {
@discardableResult
static func navigateToLoginFromOnboarding(app: XCUIApplication) -> LoginScreenObject {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapAlreadyHaveAccount()
let login = LoginScreenObject(app: app)
login.waitForLoad()
return login
}
@discardableResult
static func navigateStartFreshToCreateAccount(
app: XCUIApplication,
residenceName: String = "UI Test Residence"
) -> OnboardingCreateAccountScreen {
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.enterResidenceName(residenceName)
nameResidence.tapContinue()
let createAccount = OnboardingCreateAccountScreen(app: app)
createAccount.waitForLoad()
return createAccount
}
/// Type credentials into the login screen and tap login.
/// Assumes the app is already showing the login screen.
static func loginWithCredentials(app: XCUIApplication, username: String, password: String) {
let login = LoginScreenObject(app: app)
login.waitForLoad()
login.enterUsername(username)
login.enterPassword(password)
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitUntilHittable(timeout: 10).tap()
}
/// Drive the full forgot password verify code reset password flow using the debug code.
static func completeForgotPasswordFlow(
app: XCUIApplication,
email: String,
newPassword: String,
confirmPassword: String? = nil
) {
let confirm = confirmPassword ?? newPassword
// Step 1: Enter email on forgot password screen
let forgotScreen = ForgotPasswordScreen(app: app)
forgotScreen.waitForLoad()
forgotScreen.enterEmail(email)
forgotScreen.tapSendCode()
// Step 2: Enter debug verification code
let verifyScreen = VerifyResetCodeScreen(app: app)
verifyScreen.waitForLoad()
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
verifyScreen.tapVerify()
// Step 3: Enter new password
let resetScreen = ResetPasswordScreen(app: app)
resetScreen.waitForLoad()
resetScreen.enterNewPassword(newPassword)
resetScreen.enterConfirmPassword(confirm)
resetScreen.tapReset()
}
@discardableResult
static func openRegisterFromLogin(app: XCUIApplication) -> RegisterScreenObject {
let login: LoginScreenObject
let loginRoot = app.otherElements[UITestID.Root.login]
if loginRoot.exists || app.textFields[UITestID.Auth.usernameField].exists {
login = LoginScreenObject(app: app)
login.waitForLoad()
} else {
login = navigateToLoginFromOnboarding(app: app)
}
login.tapSignUp()
let register = RegisterScreenObject(app: app)
register.waitForLoad()
return register
}
}

View File

@@ -0,0 +1,97 @@
import XCTest
/// Base class for all page objects providing common waiting and assertion utilities.
///
/// Replaces ad-hoc `sleep()` calls with condition-based waits for reliable,
/// non-flaky UI tests. All screen page objects should inherit from this class.
class BaseScreen {
let app: XCUIApplication
let timeout: TimeInterval
init(app: XCUIApplication, timeout: TimeInterval = 10) {
self.app = app
self.timeout = timeout
}
// MARK: - Wait Helpers (replaces fixed sleeps)
/// Waits for an element to exist within the timeout period.
/// Fails the test with a descriptive message if the element does not appear.
@discardableResult
func waitForElement(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement {
let t = timeout ?? self.timeout
XCTAssertTrue(element.waitForExistence(timeout: t), "Element \(element) did not appear within \(t)s")
return element
}
/// Waits for an element to disappear within the timeout period.
/// Fails the test if the element is still present after the timeout.
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval? = nil) {
let t = timeout ?? self.timeout
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: t)
XCTAssertEqual(result, .completed, "Element \(element) did not disappear within \(t)s")
}
/// Waits for an element to become hittable (visible and interactable).
/// Returns the element for chaining.
@discardableResult
func waitForHittable(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement {
let t = timeout ?? self.timeout
let predicate = NSPredicate(format: "isHittable == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
_ = XCTWaiter().wait(for: [expectation], timeout: t)
return element
}
/// Waits until a condition evaluates to true, polling every 0.5s.
/// More flexible than element-based waits for complex state checks.
func waitForCondition(
_ description: String,
timeout: TimeInterval? = nil,
condition: () -> Bool
) -> Bool {
let t = timeout ?? self.timeout
let deadline = Date().addingTimeInterval(t)
while Date() < deadline {
if condition() { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
return false
}
/// Waits for an element to exist, then taps it. Convenience for the common wait+tap pattern.
@discardableResult
func tapElement(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement {
waitForElement(element, timeout: timeout)
element.tap()
return element
}
// MARK: - State Assertions
/// Asserts that an element with the given accessibility identifier exists.
func assertExists(_ identifier: String, file: StaticString = #file, line: UInt = #line) {
let element = app.descendants(matching: .any)[identifier]
XCTAssertTrue(element.waitForExistence(timeout: timeout), "Element '\(identifier)' not found", file: file, line: line)
}
/// Asserts that an element with the given accessibility identifier does not exist.
func assertNotExists(_ identifier: String, file: StaticString = #file, line: UInt = #line) {
let element = app.descendants(matching: .any)[identifier]
XCTAssertFalse(element.exists, "Element '\(identifier)' should not exist", file: file, line: line)
}
// MARK: - Navigation
/// Taps the first button in the navigation bar (typically the back button).
func tapBackButton() {
app.navigationBars.buttons.element(boundBy: 0).tap()
}
/// Subclasses must override this property to indicate whether the screen is currently displayed.
var isDisplayed: Bool {
fatalError("Subclasses must override isDisplayed")
}
}

View File

@@ -0,0 +1,86 @@
import XCTest
/// Page object for the login screen.
///
/// Uses accessibility identifiers from `AccessibilityIdentifiers.Authentication`
/// to locate elements. Provides typed actions for login flow interactions.
class LoginScreen: BaseScreen {
// MARK: - Elements
var emailField: XCUIElement {
app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
}
var passwordField: XCUIElement {
// Password field may be a SecureTextField or regular TextField depending on visibility toggle
let secure = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
if secure.exists { return secure }
return app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
}
var loginButton: XCUIElement {
app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
}
var appleSignInButton: XCUIElement {
app.buttons[AccessibilityIdentifiers.Authentication.appleSignInButton]
}
var signUpButton: XCUIElement {
app.buttons[AccessibilityIdentifiers.Authentication.signUpButton]
}
var forgotPasswordButton: XCUIElement {
app.buttons[AccessibilityIdentifiers.Authentication.forgotPasswordButton]
}
var passwordVisibilityToggle: XCUIElement {
app.buttons[AccessibilityIdentifiers.Authentication.passwordVisibilityToggle]
}
var welcomeText: XCUIElement {
app.staticTexts["Welcome Back"]
}
override var isDisplayed: Bool {
emailField.waitForExistence(timeout: timeout)
}
// MARK: - Actions
/// Logs in with the provided credentials and returns a MainTabScreen.
/// Waits for the email field to appear before typing.
@discardableResult
func login(email: String, password: String) -> MainTabScreen {
waitForElement(emailField).tap()
emailField.typeText(email)
let pwField = passwordField
pwField.tap()
pwField.typeText(password)
loginButton.tap()
return MainTabScreen(app: app)
}
/// Taps the sign up / register link and returns a RegisterScreen.
@discardableResult
func tapSignUp() -> RegisterScreen {
waitForElement(signUpButton).tap()
return RegisterScreen(app: app)
}
/// Taps the forgot password link.
func tapForgotPassword() {
waitForElement(forgotPasswordButton).tap()
}
/// Toggles password visibility and returns whether the password is now visible.
@discardableResult
func togglePasswordVisibility() -> Bool {
waitForElement(passwordVisibilityToggle).tap()
// If a regular text field with the password identifier exists, password is visible
return app.textFields[AccessibilityIdentifiers.Authentication.passwordField].exists
}
}

View File

@@ -0,0 +1,92 @@
import XCTest
/// Page object for the main tab view that appears after login.
///
/// The app has 4 tabs: Residences, Tasks, Contractors, Documents.
/// Profile is accessed via the settings button on the Residences screen.
/// Uses accessibility identifiers for reliable element lookup.
class MainTabScreen: BaseScreen {
// MARK: - Tab Elements
var residencesTab: XCUIElement {
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.residencesTab]
}
var tasksTab: XCUIElement {
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.tasksTab]
}
var contractorsTab: XCUIElement {
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.contractorsTab]
}
var documentsTab: XCUIElement {
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.documentsTab]
}
/// Settings button on the Residences tab (leads to profile/settings).
var settingsButton: XCUIElement {
app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
}
override var isDisplayed: Bool {
residencesTab.waitForExistence(timeout: timeout)
}
// MARK: - Navigation
@discardableResult
func goToResidences() -> Self {
waitForElement(residencesTab).tap()
return self
}
@discardableResult
func goToTasks() -> Self {
waitForElement(tasksTab).tap()
return self
}
@discardableResult
func goToContractors() -> Self {
waitForElement(contractorsTab).tap()
return self
}
@discardableResult
func goToDocuments() -> Self {
waitForElement(documentsTab).tap()
return self
}
/// Navigates to settings/profile via the settings button on Residences tab.
@discardableResult
func goToSettings() -> Self {
goToResidences()
waitForElement(settingsButton).tap()
return self
}
// MARK: - Logout
/// Logs out by navigating to settings and tapping the logout button.
/// Handles the confirmation alert automatically.
func logout() {
goToSettings()
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
if logoutButton.waitForExistence(timeout: 5) {
waitForHittable(logoutButton).tap()
// Handle confirmation alert
let alert = app.alerts.firstMatch
if alert.waitForExistence(timeout: 3) {
let confirmLogout = alert.buttons["Log Out"]
if confirmLogout.exists {
confirmLogout.tap()
}
}
}
}
}

View File

@@ -0,0 +1,86 @@
import XCTest
/// Page object for the registration screen.
///
/// Uses accessibility identifiers from `AccessibilityIdentifiers.Authentication`
/// to locate registration form elements and perform sign-up actions.
class RegisterScreen: BaseScreen {
// MARK: - Elements
var usernameField: XCUIElement {
app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
}
var emailField: XCUIElement {
app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
}
var passwordField: XCUIElement {
app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
}
var confirmPasswordField: XCUIElement {
app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
}
var registerButton: XCUIElement {
app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
}
var cancelButton: XCUIElement {
app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton]
}
/// Fallback element lookup for the register/create account button using predicate
var registerButtonByLabel: XCUIElement {
app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Register' OR label CONTAINS[c] 'Create Account'")).firstMatch
}
override var isDisplayed: Bool {
// Registration screen is visible if any of the register-specific fields exist
let usernameExists = usernameField.waitForExistence(timeout: timeout)
let emailExists = emailField.exists
return usernameExists || emailExists
}
// MARK: - Actions
/// Fills in the registration form and submits it.
/// Returns a MainTabScreen assuming successful registration leads to the main app.
@discardableResult
func register(username: String, email: String, password: String) -> MainTabScreen {
waitForElement(usernameField).tap()
usernameField.typeText(username)
emailField.tap()
emailField.typeText(email)
passwordField.tap()
passwordField.typeText(password)
confirmPasswordField.tap()
confirmPasswordField.typeText(password)
// Try accessibility identifier first, fall back to label search
if registerButton.exists {
registerButton.tap()
} else {
registerButtonByLabel.tap()
}
return MainTabScreen(app: app)
}
/// Taps cancel to return to the login screen.
@discardableResult
func tapCancel() -> LoginScreen {
if cancelButton.exists {
cancelButton.tap()
} else {
// Fall back to navigation back button
tapBackButton()
}
return LoginScreen(app: app)
}
}

View File

@@ -0,0 +1,83 @@
# honeyDue iOS UI Testing Architecture
## Directory Structure
```
HoneyDueUITests/
├── PageObjects/ # Screen abstractions (Page Object pattern)
│ ├── BaseScreen.swift # Common wait/assert utilities
│ ├── LoginScreen.swift # Login screen elements and actions
│ ├── RegisterScreen.swift # Registration screen
│ └── MainTabScreen.swift # Main tab navigation + settings + logout
├── TestConfiguration/ # Launch config, environment setup
│ └── TestLaunchConfig.swift
├── Fixtures/ # Test data builders
│ └── TestFixtures.swift
├── CriticalPath/ # Must-pass tests for CI gating
│ ├── SmokeTests.swift # Fast smoke suite (<2 min)
│ ├── AuthCriticalPathTests.swift # Auth flow validation
│ └── NavigationCriticalPathTests.swift # Tab + navigation validation
├── UITestHelpers.swift # Shared login/logout/navigation helpers
├── AccessibilityIdentifiers.swift # UI element IDs (synced with app-side copy)
└── README.md # This file
```
## Test Suites
| Suite | Purpose | CI Gate | Target Time |
|-------|---------|---------|-------------|
| SmokeTests | App launches, basic auth, tab existence | Every PR | <2 min |
| AuthCriticalPathTests | Login, logout, registration entry, forgot password | Every PR | <3 min |
| NavigationCriticalPathTests | Tab navigation, settings, add buttons | Every PR | <3 min |
## Patterns
### Page Object Pattern
Every screen has a corresponding PageObject in `PageObjects/`. Use these instead of raw XCUIElement queries in tests. Page objects encapsulate element lookups and common actions, making tests more readable and easier to maintain when the UI changes.
### Wait Helpers
NEVER use `sleep()` or `Thread.sleep()`. Use `waitForElement()`, `waitForElementToDisappear()`, `waitForHittable()`, or `waitForCondition()` from BaseScreen. These are condition-based waits that return as soon as the condition is met, making tests both faster and more reliable.
### Test Data
Use `TestFixtures` builders for consistent, unique test data. Random numbers and UUIDs ensure test isolation so tests can run in any order without interfering with each other.
### Launch Configuration
Use `TestLaunchConfig.launchApp()` for standard launches. Use `launchAuthenticated()` to skip login when the app supports test authentication bypass. The standard configuration disables animations and forces English locale.
### Accessibility Identifiers
All interactive elements must have identifiers defined in `AccessibilityIdentifiers.swift`. Use `.accessibilityIdentifier()` in SwiftUI views. Page objects reference these identifiers for element lookup. The test-side copy must stay in sync with the app-side copy at `iosApp/Helpers/AccessibilityIdentifiers.swift`.
## CI Configuration
### Critical Path (every PR)
```bash
xcodebuild test -project iosApp.xcodeproj -scheme iosApp \
-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:HoneyDueUITests/SmokeTests \
-only-testing:HoneyDueUITests/AuthCriticalPathTests \
-only-testing:HoneyDueUITests/NavigationCriticalPathTests
```
### Full Regression (nightly)
```bash
xcodebuild test -project iosApp.xcodeproj -scheme iosApp \
-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:HoneyDueUITests
```
## Flake Reduction
- Target: <2% flake rate on critical-path suite
- All waits use condition-based predicates (zero fixed sleeps)
- Test data uses unique identifiers to prevent cross-test interference
- UI animations disabled via launch arguments
- Element lookups use accessibility identifiers exclusively
## Adding New Tests
1. If the screen does not have a page object yet, create one in `PageObjects/` that extends `BaseScreen`.
2. Define accessibility identifiers in `AccessibilityIdentifiers.swift` for any new UI elements.
3. Sync the app-side copy of `AccessibilityIdentifiers.swift` with matching identifiers.
4. Add test data builders to `TestFixtures.swift` if needed.
5. Write the test in `CriticalPath/` for must-pass CI tests.
6. Verify zero `sleep()` calls before merging.

View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Script to clean up test users from Django database
# Usage: ./cleanup_test_users.sh [email]
# If no email provided, cleans up all users with email starting with 'test_'
EMAIL="$1"
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI
if [ -n "$EMAIL" ]; then
FILTER="email='$EMAIL'"
else
FILTER="email__startswith='test_'"
fi
# Try docker exec first (if running in Docker)
if docker ps --format '{{.Names}}' | grep -q 'mycrib-web\|myCrib-web'; then
CONTAINER_NAME=$(docker ps --format '{{.Names}}' | grep -E 'mycrib-web|myCrib-web' | head -1)
docker exec "$CONTAINER_NAME" python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
deleted = User.objects.filter($FILTER).delete()
print(f'Deleted: {deleted}')
" 2>/dev/null
else
# Fallback to local Python
export DJANGO_SETTINGS_MODULE=myCrib.settings
python manage.py shell -c "
from django.contrib.auth import get_user_model
User = get_user_model()
deleted = User.objects.filter($FILTER).delete()
print(f'Deleted: {deleted}')
" 2>/dev/null
fi
echo "Test users cleanup complete"

View File

@@ -0,0 +1,57 @@
#!/bin/bash
# Script to fetch verification code from Django database
# Usage: ./get_verification_code.sh <email>
# Output: Writes the verification code to /tmp/mycrib_verification_code_<sanitized_email>.txt
EMAIL="$1"
if [ -z "$EMAIL" ]; then
echo "Usage: $0 <email>"
exit 1
fi
# Sanitize email for filename
SANITIZED_EMAIL=$(echo "$EMAIL" | sed 's/@/_at_/g' | sed 's/\./_dot_/g')
OUTPUT_FILE="/tmp/mycrib_verification_code_${SANITIZED_EMAIL}.txt"
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI
# Try docker exec first (if running in Docker)
if docker ps --format '{{.Names}}' | grep -q 'mycrib-web\|myCrib-web'; then
CONTAINER_NAME=$(docker ps --format '{{.Names}}' | grep -E 'mycrib-web|myCrib-web' | head -1)
CODE=$(docker exec "$CONTAINER_NAME" python manage.py shell -c "
from user.models import ConfirmationCode
from django.contrib.auth import get_user_model
User = get_user_model()
try:
user = User.objects.get(email='$EMAIL')
code = ConfirmationCode.objects.filter(user=user, is_used=False).latest('created_at')
print(code.code)
except Exception as e:
print('ERROR:', e)
" 2>/dev/null)
else
# Fallback to local Python
export DJANGO_SETTINGS_MODULE=myCrib.settings
CODE=$(python manage.py shell -c "
from user.models import ConfirmationCode
from django.contrib.auth import get_user_model
User = get_user_model()
try:
user = User.objects.get(email='$EMAIL')
code = ConfirmationCode.objects.filter(user=user, is_used=False).latest('created_at')
print(code.code)
except Exception as e:
print('ERROR:', e)
" 2>/dev/null)
fi
# Check if we got a valid 6-digit code
if [[ "$CODE" =~ ^[0-9]{6}$ ]]; then
echo "$CODE" > "$OUTPUT_FILE"
echo "Verification code saved to $OUTPUT_FILE: $CODE"
exit 0
else
echo "Failed to get verification code: $CODE"
exit 1
fi

View File

@@ -0,0 +1,62 @@
import XCTest
/// Simple test to verify basic app launch and login screen
/// This is the foundation test - if this works, we can build more complex tests
final class SimpleLoginTest: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
override func setUpWithError() throws {
try super.setUpWithError()
// CRITICAL: Ensure we're logged out before each test
ensureLoggedOut()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
}
// MARK: - Helper Methods
/// Ensures the user is logged out and on the login screen
private func ensureLoggedOut() {
UITestHelpers.ensureLoggedOut(app: app)
}
// MARK: - Tests
/// Test 1: App launches and shows login screen (or logs out if needed)
func testAppLaunchesAndShowsLoginScreen() {
// After ensureLoggedOut(), we should be on login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.exists, "Login screen with 'Welcome Back' text should appear after logout")
// Also check that we have a username field
let usernameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch
XCTAssertTrue(usernameField.exists, "Username/email field should exist")
}
/// Test 2: Can type in username and password fields
func testCanTypeInLoginFields() {
// Already logged out from setUp
// Find and tap username field
let usernameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch
XCTAssertTrue(usernameField.waitForExistence(timeout: 10), "Username field should exist")
usernameField.tap()
usernameField.typeText("testuser")
// Find password field (could be TextField or SecureField)
let passwordField = app.secureTextFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'password'")).firstMatch
XCTAssertTrue(passwordField.exists, "Password field should exist")
passwordField.tap()
passwordField.typeText("testpass123")
// Verify we can see a Sign In button
let signInButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign In'")).firstMatch
XCTAssertTrue(signInButton.exists, "Sign In button should exist")
}
}

View File

@@ -0,0 +1,247 @@
import XCTest
/// Onboarding flow tests
///
/// SETUP REQUIREMENTS:
/// This test suite requires the app to be UNINSTALLED before running.
/// Add a Pre-action script to the honeyDueUITests scheme (Edit Scheme Test Pre-actions):
/// /usr/bin/xcrun simctl uninstall booted com.tt.honeyDue.HoneyDueDev
/// exit 0
///
/// There is ONE fresh-install test that runs the complete onboarding flow.
/// Additional tests for returning users (login screen) can run without fresh install.
final class Suite0_OnboardingTests: BaseUITestCase {
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
override func setUpWithError() throws {
try super.setUpWithError()
sleep(2)
}
override func tearDownWithError() throws {
app.terminate()
try super.tearDownWithError()
}
private func typeText(_ text: String, into field: XCUIElement) {
field.waitForExistenceOrFail(timeout: 10)
for _ in 0..<3 {
if !field.isHittable {
app.swipeUp()
}
field.forceTap()
if !field.hasKeyboardFocus {
field.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)).tap()
}
if !field.hasKeyboardFocus {
continue
}
app.typeText(text)
if let value = field.value as? String {
if value.contains(text) || value.count >= text.count {
return
}
}
}
XCTFail("Unable to enter text into \(field)")
}
private func dismissStrongPasswordSuggestionIfPresent() {
let chooseOwnPassword = app.buttons["Choose My Own Password"]
if chooseOwnPassword.waitForExistence(timeout: 1) {
chooseOwnPassword.tap()
return
}
let notNow = app.buttons["Not Now"]
if notNow.exists && notNow.isHittable {
notNow.tap()
}
}
private func focusField(_ field: XCUIElement, name: String) {
field.waitForExistenceOrFail(timeout: 10)
for _ in 0..<4 {
if field.hasKeyboardFocus { return }
field.forceTap()
if field.hasKeyboardFocus { return }
field.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap()
if field.hasKeyboardFocus { return }
}
XCTFail("Failed to focus \(name) field")
}
func test_onboarding() {
app.activate()
sleep(2)
let springboardApp = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let allowButton = springboardApp.buttons["Allow"].firstMatch
if allowButton.waitForExistence(timeout: 2) {
allowButton.tap()
}
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valuePropsTitle = app.descendants(matching: .any).matching(identifier: AccessibilityIdentifiers.Onboarding.valuePropsTitle).firstMatch
if valuePropsTitle.waitForExistence(timeout: 5) {
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.tapContinue()
}
let nameResidenceTitle = app.descendants(matching: .any).matching(identifier: AccessibilityIdentifiers.Onboarding.nameResidenceTitle).firstMatch
if nameResidenceTitle.waitForExistence(timeout: 5) {
let residenceField = app.textFields[AccessibilityIdentifiers.Onboarding.residenceNameField]
residenceField.waitUntilHittable(timeout: 8).tap()
residenceField.typeText("xcuitest")
app.descendants(matching: .any).matching(identifier: AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton).firstMatch.waitUntilHittable(timeout: 8).tap()
}
let emailExpandButton = app.buttons[AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton].firstMatch
if emailExpandButton.waitForExistence(timeout: 10) && emailExpandButton.isHittable {
emailExpandButton.tap()
}
let unique = Int(Date().timeIntervalSince1970)
let onboardingUsername = "xcuitest\(unique)"
let onboardingEmail = "xcuitest_\(unique)@treymail.com"
let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField].firstMatch
focusField(usernameField, name: "username")
usernameField.typeText(onboardingUsername)
XCTAssertTrue((usernameField.value as? String)?.contains(onboardingUsername) == true, "Username should be populated")
let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField].firstMatch
emailField.waitForExistenceOrFail(timeout: 10)
var didEnterEmail = false
for _ in 0..<5 {
app.swipeUp()
emailField.forceTap()
if emailField.hasKeyboardFocus {
emailField.typeText(onboardingEmail)
didEnterEmail = true
break
}
}
XCTAssertTrue(didEnterEmail, "Email field must become focused for typing")
let strongPassword = "TestPass123!"
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.passwordField].firstMatch
dismissStrongPasswordSuggestionIfPresent()
focusField(passwordField, name: "password")
passwordField.typeText(strongPassword)
XCTAssertFalse((passwordField.value as? String)?.isEmpty ?? true, "Password should be populated")
let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField].firstMatch
dismissStrongPasswordSuggestionIfPresent()
if !confirmPasswordField.hasKeyboardFocus {
app.swipeUp()
focusField(confirmPasswordField, name: "confirm password")
}
confirmPasswordField.typeText(strongPassword)
let createAccountButtonByID = app.buttons[AccessibilityIdentifiers.Onboarding.createAccountButton]
let createAccountButtonByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Create Account'")).firstMatch
let createAccountButton = createAccountButtonByID.exists ? createAccountButtonByID : createAccountButtonByLabel
createAccountButton.waitForExistenceOrFail(timeout: 10)
if !createAccountButton.isHittable {
app.swipeUp()
sleep(1)
}
if !createAccountButton.isEnabled {
// Retry confirm-password input once when validation hasn't propagated.
let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField].firstMatch
if confirmPasswordField.waitForExistence(timeout: 3) {
focusField(confirmPasswordField, name: "confirm password retry")
confirmPasswordField.typeText(strongPassword)
}
sleep(1)
}
XCTAssertTrue(createAccountButton.isEnabled, "Create account button should be enabled after valid form entry")
createAccountButton.forceTap()
let verifyCodeField = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
verifyCodeField.waitForExistenceOrFail(timeout: 12)
verifyCodeField.forceTap()
app.typeText("123456")
let verifyButtonByID = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton]
let verifyButtonByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
let verifyButton = verifyButtonByID.exists ? verifyButtonByID : verifyButtonByLabel
verifyButton.waitForExistenceOrFail(timeout: 10)
if !verifyButton.isHittable {
app.swipeUp()
sleep(1)
}
verifyButton.forceTap()
let addPopular = app.buttons[AccessibilityIdentifiers.Onboarding.addPopularTasksButton].firstMatch
if addPopular.waitForExistence(timeout: 10) {
addPopular.tap()
} else {
app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Most Popular'")).firstMatch.tap()
}
let addTasksContinue = app.buttons[AccessibilityIdentifiers.Onboarding.addTasksContinueButton].firstMatch
if addTasksContinue.waitForExistence(timeout: 10) {
addTasksContinue.tap()
} else {
app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks & Continue'")).firstMatch.tap()
}
let continueWithFree = app.buttons[AccessibilityIdentifiers.Onboarding.continueWithFreeButton].firstMatch
if continueWithFree.waitForExistence(timeout: 10) {
continueWithFree.tap()
} else {
app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Continue with Free'")).firstMatch.tap()
}
let residencesHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Your Properties' OR label CONTAINS[c] 'My Properties' OR label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesHeader.waitForExistence(timeout: 5), "Residences list screen must be visible")
let xcuitestResidence = app.staticTexts["xcuitest"].waitForExistence(timeout: 10)
XCTAssertTrue(xcuitestResidence, "Residence should appear in list")
app/*@START_MENU_TOKEN@*/.images["checkmark.circle.fill"]/*[[".buttons[\"checkmark.circle.fill\"].images",".buttons",".images[\"selected\"]",".images[\"checkmark.circle.fill\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
let taskOne = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "HVAC")).firstMatch
XCTAssertTrue(taskOne.waitForExistence(timeout: 10), "HVAC task should appear in list")
let taskTwo = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Leaks")).firstMatch
XCTAssertTrue(taskTwo.waitForExistence(timeout: 10), "Leaks task should appear in list")
let taskThree = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Coils")).firstMatch
XCTAssertTrue(taskThree.waitForExistence(timeout: 10), "Coils task should appear in list")
// Try profile tab logout
let profileTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch
if profileTab.exists && profileTab.isHittable {
profileTab.tap()
let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
if logoutButton.waitForExistence(timeout: 3) && logoutButton.isHittable {
logoutButton.tap()
// Handle confirmation alert
let alertLogout = app.alerts.buttons["Log Out"]
if alertLogout.waitForExistence(timeout: 2) {
alertLogout.tap()
}
}
}
// Try verification screen logout
let verifyLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch
if verifyLogout.exists && verifyLogout.isHittable {
verifyLogout.tap()
}
// Wait for login screen
_ = app.textFields[AccessibilityIdentifiers.Authentication.usernameField].waitForExistence(timeout: 8)
}
}

View File

@@ -0,0 +1,683 @@
import XCTest
/// Comprehensive End-to-End Test Suite
/// Closely mirrors TestIntegration_ComprehensiveE2E from honeyDueAPI-go/internal/integration/integration_test.go
///
/// This test creates a complete scenario:
/// 1. Registers a new user and verifies login
/// 2. Creates multiple residences
/// 3. Creates multiple tasks in different states
/// 4. Verifies task categorization in kanban columns
/// 5. Tests task state transitions (in-progress, complete, cancel, archive)
///
/// IMPORTANT: These are integration tests requiring network connectivity.
/// Run against a test/dev server, NOT production.
final class Suite10_ComprehensiveE2ETests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
// Test run identifier for unique data - use static so it's shared across test methods
private static let testRunId = Int(Date().timeIntervalSince1970)
// Test user credentials - unique per test run
private var testUsername: String { "e2e_comp_\(Self.testRunId)" }
private var testEmail: String { "e2e_comp_\(Self.testRunId)@test.com" }
private let testPassword = "TestPass123!"
/// Fixed verification code used by Go API when DEBUG=true
private let verificationCode = "123456"
/// Track if user has been registered for this test run
private static var userRegistered = false
override func setUpWithError() throws {
try super.setUpWithError()
// Register user on first test, then just ensure logged in for subsequent tests
if !Self.userRegistered {
registerTestUser()
Self.userRegistered = true
} else {
UITestHelpers.ensureLoggedIn(app: app, username: testUsername, password: testPassword)
}
}
override func tearDownWithError() throws {
try super.tearDownWithError()
}
/// Register a new test user for this test suite
private func registerTestUser() {
// Check if already logged in
let tabBar = app.tabBars.firstMatch
if tabBar.exists {
return // Already logged in
}
// Check if on login screen, navigate to register
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
if welcomeText.waitForExistence(timeout: 5) {
let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch
if signUpButton.exists {
signUpButton.tap()
sleep(2)
}
}
// Fill registration form
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
if usernameField.waitForExistence(timeout: 5) {
usernameField.tap()
usernameField.typeText(testUsername)
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
emailField.tap()
emailField.typeText(testEmail)
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
passwordField.tap()
dismissStrongPasswordSuggestion()
passwordField.typeText(testPassword)
let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
confirmPasswordField.tap()
dismissStrongPasswordSuggestion()
confirmPasswordField.typeText(testPassword)
dismissKeyboard()
sleep(1)
// Submit registration
app.swipeUp()
sleep(1)
var registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
if !registerButton.exists || !registerButton.isHittable {
registerButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Create Account' OR label CONTAINS[c] 'Register'")).firstMatch
}
if registerButton.exists {
registerButton.tap()
sleep(3)
}
// Handle email verification
let verifyEmailTitle = app.staticTexts["Verify Your Email"]
if verifyEmailTitle.waitForExistence(timeout: 10) {
let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
if codeField.waitForExistence(timeout: 5) {
codeField.tap()
codeField.typeText(verificationCode)
sleep(5)
}
}
// Wait for login to complete
_ = tabBar.waitForExistence(timeout: 15)
}
}
/// Dismiss strong password suggestion if shown
private func dismissStrongPasswordSuggestion() {
let chooseOwnPassword = app.buttons["Choose My Own Password"]
if chooseOwnPassword.waitForExistence(timeout: 1) {
chooseOwnPassword.tap()
return
}
let notNow = app.buttons["Not Now"]
if notNow.exists && notNow.isHittable {
notNow.tap()
}
}
// MARK: - Helper Methods
private func navigateToTab(_ tabName: String) {
let tab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(tabName)'")).firstMatch
if tab.waitForExistence(timeout: 5) && !tab.isSelected {
tab.tap()
sleep(2)
}
}
/// Dismiss keyboard by tapping outside (doesn't submit forms)
private func dismissKeyboard() {
// Tap on a neutral area to dismiss keyboard without submitting
let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1))
coordinate.tap()
Thread.sleep(forTimeInterval: 0.5)
}
/// Creates a residence with the given name
/// Returns true if successful
@discardableResult
private func createResidence(name: String, streetAddress: String = "123 Test St", city: String = "Austin", state: String = "TX", postalCode: String = "78701") -> Bool {
navigateToTab("Residences")
sleep(2)
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
guard addButton.waitForExistence(timeout: 5) else {
XCTFail("Add residence button not found")
return false
}
addButton.tap()
sleep(2)
// Fill name
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
guard nameField.waitForExistence(timeout: 5) else {
XCTFail("Name field not found")
return false
}
nameField.tap()
nameField.typeText(name)
// Fill address
fillTextField(placeholder: "Street", text: streetAddress)
fillTextField(placeholder: "City", text: city)
fillTextField(placeholder: "State", text: state)
fillTextField(placeholder: "Postal", text: postalCode)
app.swipeUp()
sleep(1)
// Save
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
guard saveButton.exists else {
XCTFail("Save button not found")
return false
}
saveButton.tap()
sleep(3)
// Verify created
let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch
return residenceCard.waitForExistence(timeout: 10)
}
/// Creates a task with the given title
/// Returns true if successful
@discardableResult
private func createTask(title: String, description: String? = nil) -> Bool {
navigateToTab("Tasks")
sleep(2)
let addButton = findAddTaskButton()
guard addButton.waitForExistence(timeout: 5) && addButton.isEnabled else {
XCTFail("Add task button not found or disabled")
return false
}
addButton.tap()
sleep(2)
// Fill title
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
guard titleField.waitForExistence(timeout: 5) else {
XCTFail("Title field not found")
return false
}
titleField.tap()
titleField.typeText(title)
// Fill description if provided
if let desc = description {
let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch
if descField.exists {
descField.tap()
descField.typeText(desc)
}
}
app.swipeUp()
sleep(1)
// Save
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
guard saveButton.exists else {
XCTFail("Save button not found")
return false
}
saveButton.tap()
sleep(3)
// Verify created
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch
return taskCard.waitForExistence(timeout: 10)
}
private func fillTextField(placeholder: String, text: String) {
let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch
if field.exists {
field.tap()
field.typeText(text)
}
}
private func findAddTaskButton() -> XCUIElement {
// Strategy 1: Accessibility identifier
let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
if addButtonById.exists && addButtonById.isEnabled {
return addButtonById
}
// Strategy 2: Navigation bar plus button
let navBarButtons = app.navigationBars.buttons
for i in 0..<navBarButtons.count {
let button = navBarButtons.element(boundBy: i)
if (button.label == "plus" || button.label.contains("Add")) && button.isEnabled {
return button
}
}
// Strategy 3: Empty state button
let emptyStateButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task'")).firstMatch
if emptyStateButton.exists && emptyStateButton.isEnabled {
return emptyStateButton
}
return addButtonById
}
// MARK: - Test 1: Create Multiple Residences
// Phase 2 of TestIntegration_ComprehensiveE2E
func test01_createMultipleResidences() {
let residenceNames = [
"E2E Main House \(Self.testRunId)",
"E2E Beach House \(Self.testRunId)",
"E2E Mountain Cabin \(Self.testRunId)"
]
for (index, name) in residenceNames.enumerated() {
let streetAddress = "\(100 * (index + 1)) Test St"
let success = createResidence(name: name, streetAddress: streetAddress)
XCTAssertTrue(success, "Should create residence: \(name)")
}
// Verify all residences exist
navigateToTab("Residences")
sleep(2)
for name in residenceNames {
let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch
XCTAssertTrue(residenceCard.waitForExistence(timeout: 5), "Residence '\(name)' should exist in list")
}
}
// MARK: - Test 2: Create Tasks with Various States
// Phase 3 of TestIntegration_ComprehensiveE2E
func test02_createTasksWithVariousStates() {
// Ensure at least one residence exists
navigateToTab("Residences")
sleep(2)
let emptyState = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
if emptyState.exists {
createResidence(name: "Task Test Residence \(Self.testRunId)")
}
// Create tasks with different purposes
let tasks = [
("E2E Active Task \(Self.testRunId)", "Task that remains active"),
("E2E Progress Task \(Self.testRunId)", "Task to mark in-progress"),
("E2E Complete Task \(Self.testRunId)", "Task to complete"),
("E2E Cancel Task \(Self.testRunId)", "Task to cancel")
]
for (title, description) in tasks {
let success = createTask(title: title, description: description)
XCTAssertTrue(success, "Should create task: \(title)")
}
// Verify all tasks exist
navigateToTab("Tasks")
sleep(2)
for (title, _) in tasks {
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch
XCTAssertTrue(taskCard.waitForExistence(timeout: 5), "Task '\(title)' should exist")
}
}
// MARK: - Test 3: Task State Transitions
// Mirrors task operations from TestIntegration_TaskFlow
func test03_taskStateTransitions() {
navigateToTab("Tasks")
sleep(2)
// Find a task to transition (create one if needed)
let testTaskTitle = "E2E State Test \(Self.testRunId)"
var taskExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists
if !taskExists {
// Check if any residence exists first
navigateToTab("Residences")
sleep(2)
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
if emptyResidences.exists {
createResidence(name: "State Test Residence \(Self.testRunId)")
}
createTask(title: testTaskTitle, description: "Testing state transitions")
navigateToTab("Tasks")
sleep(2)
}
// Find and tap the task
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
if taskCard.waitForExistence(timeout: 5) {
taskCard.tap()
sleep(2)
// Try to mark in progress
let inProgressButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'In Progress' OR label CONTAINS[c] 'Start'")).firstMatch
if inProgressButton.exists && inProgressButton.isEnabled {
inProgressButton.tap()
sleep(2)
}
// Try to complete
let completeButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Complete' OR label CONTAINS[c] 'Mark Complete'")).firstMatch
if completeButton.exists && completeButton.isEnabled {
completeButton.tap()
sleep(2)
// Handle completion form if shown
let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Submit' OR label CONTAINS[c] 'Save'")).firstMatch
if submitButton.waitForExistence(timeout: 2) {
submitButton.tap()
sleep(2)
}
}
// Navigate back
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.exists && backButton.isHittable {
backButton.tap()
sleep(1)
}
}
}
// MARK: - Test 4: Task Cancel Operation
func test04_taskCancelOperation() {
navigateToTab("Tasks")
sleep(2)
let testTaskTitle = "E2E Cancel Test \(Self.testRunId)"
// Create task if doesn't exist
if !app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists {
navigateToTab("Residences")
sleep(1)
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
if emptyResidences.exists {
createResidence(name: "Cancel Test Residence \(Self.testRunId)")
}
createTask(title: testTaskTitle, description: "Task to be cancelled")
navigateToTab("Tasks")
sleep(2)
}
// Find and tap task
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
if taskCard.waitForExistence(timeout: 5) {
taskCard.tap()
sleep(2)
// Look for cancel button
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel Task' OR label CONTAINS[c] 'Cancel'")).firstMatch
if cancelButton.exists && cancelButton.isEnabled {
cancelButton.tap()
sleep(1)
// Confirm cancellation if alert shown
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel' OR label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes'")).firstMatch
if confirmButton.exists {
confirmButton.tap()
sleep(2)
}
}
// Navigate back
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.exists && backButton.isHittable {
backButton.tap()
sleep(1)
}
}
}
// MARK: - Test 5: Task Archive Operation
func test05_taskArchiveOperation() {
navigateToTab("Tasks")
sleep(2)
let testTaskTitle = "E2E Archive Test \(Self.testRunId)"
// Create task if doesn't exist
if !app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists {
navigateToTab("Residences")
sleep(1)
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
if emptyResidences.exists {
createResidence(name: "Archive Test Residence \(Self.testRunId)")
}
createTask(title: testTaskTitle, description: "Task to be archived")
navigateToTab("Tasks")
sleep(2)
}
// Find and tap task
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
if taskCard.waitForExistence(timeout: 5) {
taskCard.tap()
sleep(2)
// Look for archive button
let archiveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Archive'")).firstMatch
if archiveButton.exists && archiveButton.isEnabled {
archiveButton.tap()
sleep(1)
// Confirm archive if alert shown
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Archive' OR label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes'")).firstMatch
if confirmButton.exists {
confirmButton.tap()
sleep(2)
}
}
// Navigate back
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.exists && backButton.isHittable {
backButton.tap()
sleep(1)
}
}
}
// MARK: - Test 6: Verify Kanban Column Structure
// Phase 6 of TestIntegration_ComprehensiveE2E
func test06_verifyKanbanStructure() {
navigateToTab("Tasks")
sleep(3)
// Expected kanban column names (may vary by implementation)
let expectedColumns = [
"Overdue",
"In Progress",
"Due Soon",
"Upcoming",
"Completed",
"Cancelled"
]
var foundColumns: [String] = []
for column in expectedColumns {
let columnHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(column)'")).firstMatch
if columnHeader.exists {
foundColumns.append(column)
}
}
// Should have at least some kanban columns OR be in list view
let hasKanbanView = foundColumns.count >= 2
let hasListView = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'All Tasks'")).firstMatch.exists
XCTAssertTrue(hasKanbanView || hasListView, "Should display tasks in kanban or list view. Found columns: \(foundColumns)")
}
// MARK: - Test 7: Residence Details Show Tasks
// Verifies that residence detail screen shows associated tasks
func test07_residenceDetailsShowTasks() {
navigateToTab("Residences")
sleep(2)
// Find any residence
let residenceCard = app.cells.firstMatch
guard residenceCard.waitForExistence(timeout: 5) else {
// No residences - create one with a task
createResidence(name: "Detail Test Residence \(Self.testRunId)")
createTask(title: "Detail Test Task \(Self.testRunId)")
navigateToTab("Residences")
sleep(2)
let newResidenceCard = app.cells.firstMatch
guard newResidenceCard.waitForExistence(timeout: 5) else {
XCTFail("Could not find any residence")
return
}
newResidenceCard.tap()
sleep(2)
return
}
residenceCard.tap()
sleep(2)
// Look for tasks section in residence details
let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch
let taskCount = app.staticTexts.containing(NSPredicate(format: "label MATCHES '\\\\d+ tasks?' OR label MATCHES '\\\\d+ Tasks?'")).firstMatch
// Either tasks section header or task count should be visible
let hasTasksInfo = tasksSection.exists || taskCount.exists
// Navigate back
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.exists && backButton.isHittable {
backButton.tap()
sleep(1)
}
// Note: Not asserting because task section visibility depends on UI design
}
// MARK: - Test 8: Contractor CRUD (Mirrors backend contractor tests)
func test08_contractorCRUD() {
navigateToTab("Contractors")
sleep(2)
let contractorName = "E2E Test Contractor \(Self.testRunId)"
// Check if Contractors tab exists
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
guard contractorsTab.exists else {
// Contractors may not be a main tab - skip this test
return
}
// Try to add contractor
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton]
guard addButton.waitForExistence(timeout: 5) else {
// May need residence first
return
}
addButton.tap()
sleep(2)
// Fill contractor form
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
if nameField.exists {
nameField.tap()
nameField.typeText(contractorName)
let companyField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Company'")).firstMatch
if companyField.exists {
companyField.tap()
companyField.typeText("Test Company Inc")
}
let phoneField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Phone'")).firstMatch
if phoneField.exists {
phoneField.tap()
phoneField.typeText("555-123-4567")
}
app.swipeUp()
sleep(1)
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
if saveButton.exists {
saveButton.tap()
sleep(3)
// Verify contractor was created
let contractorCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(contractorName)'")).firstMatch
XCTAssertTrue(contractorCard.waitForExistence(timeout: 10), "Contractor '\(contractorName)' should be created")
}
} else {
// Cancel if form didn't load properly
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
if cancelButton.exists {
cancelButton.tap()
}
}
}
// MARK: - Test 9: Full Flow Summary
func test09_fullFlowSummary() {
// This test verifies the overall app state after running previous tests
// Check Residences tab
navigateToTab("Residences")
sleep(2)
let residencesList = app.cells
let residenceCount = residencesList.count
// Check Tasks tab
navigateToTab("Tasks")
sleep(2)
let tasksScreen = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
XCTAssertTrue(tasksScreen.exists, "Tasks screen should be accessible")
// Check Profile tab
navigateToTab("Profile")
sleep(2)
let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
XCTAssertTrue(logoutButton.exists, "User should be logged in with logout option available")
print("=== E2E Test Summary ===")
print("Residences found: \(residenceCount)")
print("Tasks screen accessible: true")
print("User logged in: true")
print("========================")
}
}

View File

@@ -0,0 +1,654 @@
import XCTest
/// Comprehensive registration flow tests with strict, failure-first assertions
/// Tests verify both positive AND negative conditions to ensure robust validation
final class Suite1_RegistrationTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
// Test user credentials - using timestamp to ensure unique users
private var testUsername: String {
return "testuser_\(Int(Date().timeIntervalSince1970))"
}
private var testEmail: String {
return "test_\(Int(Date().timeIntervalSince1970))@example.com"
}
private let testPassword = "Pass1234"
/// Fixed test verification code - Go API uses this code when DEBUG=true
private let testVerificationCode = "123456"
override func setUpWithError() throws {
try super.setUpWithError()
// STRICT: Verify app launched to a known state
let loginScreen = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
// If login isn't visible, force deterministic navigation to login.
if !loginScreen.waitForExistence(timeout: 3) {
ensureLoggedOut()
}
// STRICT: Must be on login screen before each test
XCTAssertTrue(loginScreen.waitForExistence(timeout: 10), "PRECONDITION FAILED: Must start on login screen")
app.swipeUp()
}
override func tearDownWithError() throws {
ensureLoggedOut()
try super.tearDownWithError()
}
// MARK: - Strict Helper Methods
private func ensureLoggedOut() {
UITestHelpers.ensureLoggedOut(app: app)
}
/// Navigate to registration screen with strict verification
/// Note: Registration is presented as a sheet, so login screen elements still exist underneath
private func navigateToRegistration() {
app.swipeUp()
// PRECONDITION: Must be on login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.exists, "PRECONDITION: Must be on login screen to navigate to registration")
let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch
XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button must exist on login screen")
XCTAssertTrue(signUpButton.isHittable, "Sign Up button must be tappable")
dismissKeyboard()
signUpButton.tap()
// STRICT: Verify registration screen appeared (shown as sheet)
// Note: Login screen still exists underneath the sheet, so we verify registration elements instead
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Registration username field must appear")
XCTAssertTrue(waitForElementToBeHittable(usernameField, timeout: 5), "Registration username field must be tappable")
// Keep action buttons visible for strict assertions and interactions.
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
if createAccountButton.exists && !createAccountButton.isHittable {
let scrollView = app.scrollViews.firstMatch
if scrollView.exists {
createAccountButton.scrollIntoView(in: scrollView, maxSwipes: 5)
}
}
// STRICT: The Sign Up button should no longer be hittable (covered by sheet)
XCTAssertFalse(signUpButton.isHittable, "Login Sign Up button should be covered by registration sheet")
}
/// Dismisses iOS Strong Password suggestion overlay
private func dismissStrongPasswordSuggestion() {
let chooseOwnPassword = app.buttons["Choose My Own Password"]
if chooseOwnPassword.waitForExistence(timeout: 1) {
chooseOwnPassword.tap()
return
}
let notNowButton = app.buttons["Not Now"]
if notNowButton.exists && notNowButton.isHittable {
notNowButton.tap()
return
}
// Dismiss by tapping elsewhere
let strongPasswordText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Strong Password'")).firstMatch
if strongPasswordText.exists {
app.tap()
}
}
/// Wait for element to disappear - CRITICAL for strict testing
private func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "exists == false"),
object: element
)
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
return result == .completed
}
/// Wait for element to become hittable (visible AND interactive)
private func waitForElementToBeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "isHittable == true"),
object: element
)
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
return result == .completed
}
/// Verification screen readiness check based on stable accessibility IDs.
private func waitForVerificationScreen(timeout: TimeInterval) -> Bool {
let authCodeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
let onboardingCodeField = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
let authVerifyButton = app.buttons[AccessibilityIdentifiers.Authentication.verifyButton]
let onboardingVerifyButton = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton]
return authCodeField.waitForExistence(timeout: timeout)
|| onboardingCodeField.waitForExistence(timeout: timeout)
|| authVerifyButton.waitForExistence(timeout: timeout)
|| onboardingVerifyButton.waitForExistence(timeout: timeout)
}
private func verificationCodeField() -> XCUIElement {
let authCodeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
if authCodeField.exists {
return authCodeField
}
return app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
}
private func verificationButton() -> XCUIElement {
let authVerifyButton = app.buttons[AccessibilityIdentifiers.Authentication.verifyButton]
if authVerifyButton.exists {
return authVerifyButton
}
let onboardingVerifyButton = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton]
if onboardingVerifyButton.exists {
return onboardingVerifyButton
}
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
}
/// Dismiss keyboard by swiping down on the keyboard area
private func dismissKeyboard() {
let app = XCUIApplication()
if app.keys.element(boundBy: 0).exists {
app.typeText("\n")
}
// Give a moment for keyboard to dismiss
Thread.sleep(forTimeInterval: 2)
}
/// Fill registration form with given credentials
private func fillRegistrationForm(username: String, email: String, password: String, confirmPassword: String) {
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
// STRICT: All fields must exist and be hittable
XCTAssertTrue(usernameField.isHittable, "Username field must be hittable")
XCTAssertTrue(emailField.isHittable, "Email field must be hittable")
XCTAssertTrue(passwordField.isHittable, "Password field must be hittable")
XCTAssertTrue(confirmPasswordField.isHittable, "Confirm password field must be hittable")
usernameField.tap()
usernameField.typeText(username)
emailField.tap()
emailField.typeText(email)
passwordField.tap()
dismissStrongPasswordSuggestion()
passwordField.typeText(password)
confirmPasswordField.tap()
dismissStrongPasswordSuggestion()
confirmPasswordField.typeText(confirmPassword)
// Dismiss keyboard after filling form so buttons are accessible
dismissKeyboard()
}
// MARK: - 1. UI/Element Tests (no backend, pure UI verification)
func test01_registrationScreenElements() {
navigateToRegistration()
// STRICT: All form elements must exist AND be hittable
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
let cancelButton = app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton]
XCTAssertTrue(usernameField.exists && usernameField.isHittable, "Username field must be visible and tappable")
XCTAssertTrue(emailField.exists && emailField.isHittable, "Email field must be visible and tappable")
XCTAssertTrue(passwordField.exists && passwordField.isHittable, "Password field must be visible and tappable")
XCTAssertTrue(confirmPasswordField.exists && confirmPasswordField.isHittable, "Confirm password field must be visible and tappable")
XCTAssertTrue(createAccountButton.exists && createAccountButton.isHittable, "Create Account button must be visible and tappable")
XCTAssertTrue(cancelButton.exists && cancelButton.isHittable, "Cancel button must be visible and tappable")
// NEGATIVE CHECK: Should NOT see verification screen elements as hittable
let verifyTitle = app.staticTexts["Verify Your Email"]
XCTAssertFalse(verifyTitle.exists && verifyTitle.isHittable, "Verification screen should NOT be visible on registration form")
// NEGATIVE CHECK: Login Sign Up button should not be hittable (covered by sheet)
let loginSignUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch
// Note: The button might still exist but should not be hittable due to sheet coverage
if loginSignUpButton.exists {
XCTAssertFalse(loginSignUpButton.isHittable, "Login screen's Sign Up button should be covered by registration sheet")
}
}
func test02_cancelRegistration() {
navigateToRegistration()
// Capture that we're on registration screen
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
XCTAssertTrue(usernameField.isHittable, "PRECONDITION: Must be on registration screen")
let cancelButton = app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton]
XCTAssertTrue(cancelButton.isHittable, "Cancel button must be tappable")
dismissKeyboard()
cancelButton.tap()
// STRICT: Registration sheet must dismiss - username field should no longer be hittable
XCTAssertTrue(waitForElementToDisappear(usernameField, timeout: 5), "Registration form must disappear after cancel")
// STRICT: Login screen must now be interactive again
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Login screen must be visible after cancel")
// STRICT: Sign Up button should be hittable again (sheet dismissed)
let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch
XCTAssertTrue(waitForElementToBeHittable(signUpButton, timeout: 5), "Sign Up button must be tappable after cancel")
}
// MARK: - 2. Client-Side Validation Tests (no API calls, fail locally)
func test03_registrationWithEmptyFields() {
navigateToRegistration()
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
XCTAssertTrue(createAccountButton.isHittable, "Create Account button must be tappable")
// Capture current state
let verifyTitle = app.staticTexts["Verify Your Email"]
XCTAssertFalse(verifyTitle.exists, "PRECONDITION: Should not be on verification screen")
dismissKeyboard()
createAccountButton.tap()
// STRICT: Must show error message
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'required' OR label CONTAINS[c] 'Username'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for empty fields")
// NEGATIVE CHECK: Should NOT navigate away from registration
// XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification screen with empty fields")
// STRICT: Registration form should still be visible and interactive
// let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
// XCTAssertTrue(usernameField.isHittable, "Username field should still be tappable after error")
}
func test04_registrationWithInvalidEmail() {
navigateToRegistration()
fillRegistrationForm(
username: "testuser",
email: "invalid-email", // Invalid format
password: testPassword,
confirmPassword: testPassword
)
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
dismissKeyboard()
createAccountButton.tap()
// STRICT: Must show email-specific error
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'email' OR label CONTAINS[c] 'invalid'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for invalid email format")
// NEGATIVE CHECK: Should NOT proceed to verification
let verifyTitle = app.staticTexts["Verify Your Email"]
XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with invalid email")
}
func test05_registrationWithMismatchedPasswords() {
navigateToRegistration()
fillRegistrationForm(
username: "testuser",
email: "test@example.com",
password: "Password123!",
confirmPassword: "DifferentPassword123!" // Mismatched
)
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
dismissKeyboard()
createAccountButton.tap()
// STRICT: Must show password mismatch error
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'match' OR label CONTAINS[c] 'password'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for mismatched passwords")
// NEGATIVE CHECK: Should NOT proceed to verification
let verifyTitle = app.staticTexts["Verify Your Email"]
XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with mismatched passwords")
}
func test06_registrationWithWeakPassword() {
navigateToRegistration()
fillRegistrationForm(
username: "testuser",
email: "test@example.com",
password: "weak", // Too weak
confirmPassword: "weak"
)
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
dismissKeyboard()
createAccountButton.tap()
// STRICT: Must show password strength error
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'password' OR label CONTAINS[c] 'character' OR label CONTAINS[c] 'strong' OR label CONTAINS[c] '8'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for weak password")
// NEGATIVE CHECK: Should NOT proceed
let verifyTitle = app.staticTexts["Verify Your Email"]
XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with weak password")
}
// MARK: - 3. Full Registration Flow Tests (creates new users - MUST RUN BEFORE tests that need existing users)
func test07_successfulRegistrationAndVerification() {
let username = testUsername
let email = testEmail
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
dismissKeyboard()
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
// Capture registration form state
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
// STRICT: Registration form must disappear
XCTAssertTrue(waitForElementToDisappear(usernameField, timeout: 10), "Registration form must disappear after successful registration")
// STRICT: Verification screen must appear
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Verification screen must appear after registration")
// NEGATIVE CHECK: Tab bar should NOT be hittable while on verification
let tabBar = app.tabBars.firstMatch
if tabBar.exists {
XCTAssertFalse(tabBar.isHittable, "Tab bar should NOT be interactive while verification is required")
}
// Enter verification code
let codeField = verificationCodeField()
XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist")
XCTAssertTrue(codeField.isHittable, "Verification code field must be tappable")
dismissKeyboard()
codeField.tap()
codeField.typeText(testVerificationCode)
dismissKeyboard()
let verifyButton = verificationButton()
XCTAssertTrue(verifyButton.exists && verifyButton.isHittable, "Verify button must be tappable")
verifyButton.tap()
// STRICT: Verification screen must DISAPPEAR
XCTAssertTrue(waitForElementToDisappear(codeField, timeout: 10), "Verification code field MUST disappear after successful verification")
// STRICT: Must be on main app screen
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.waitForExistence(timeout: 10), "Tab bar must appear after verification")
XCTAssertTrue(waitForElementToBeHittable(residencesTab, timeout: 5), "Residences tab MUST be tappable after verification")
// NEGATIVE CHECK: Verification screen should be completely gone
XCTAssertFalse(codeField.exists, "Verification code field must NOT exist after successful verification")
// Verify we can interact with the app (tap tab)
dismissKeyboard()
residencesTab.tap()
// Cleanup: Logout
let profileTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch
XCTAssertTrue(profileTab.waitForExistence(timeout: 5) && profileTab.isHittable, "Profile tab must be tappable")
dismissKeyboard()
profileTab.tap()
let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
XCTAssertTrue(logoutButton.waitForExistence(timeout: 5) && logoutButton.isHittable, "Logout button must be tappable")
dismissKeyboard()
logoutButton.tap()
let alertLogout = app.alerts.buttons["Log Out"]
if alertLogout.waitForExistence(timeout: 3) {
dismissKeyboard()
alertLogout.tap()
}
// STRICT: Must return to login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Must return to login screen after logout")
}
// MARK: - 4. Server-Side Validation Tests (NOW a user exists from test07)
// func test08_registrationWithExistingUsername() {
// // NOTE: test07 created a user, so now we can test duplicate username rejection
// // We use 'testuser' which should be seeded, OR we could use the username from test07
// navigateToRegistration()
//
// fillRegistrationForm(
// username: "testuser", // Existing username (seeded in test DB)
// email: "newemail_\(Int(Date().timeIntervalSince1970))@example.com",
// password: testPassword,
// confirmPassword: testPassword
// )
//
// dismissKeyboard()
// app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
//
// // STRICT: Must show "already exists" error
// let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'exists' OR label CONTAINS[c] 'already' OR label CONTAINS[c] 'taken'")
// let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
// XCTAssertTrue(errorMessage.waitForExistence(timeout: 5), "Error message must appear for existing username")
//
// // NEGATIVE CHECK: Should NOT proceed to verification
// let verifyTitle = app.staticTexts["Verify Your Email"]
// XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with existing username")
//
// // STRICT: Should still be on registration form
// let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
// XCTAssertTrue(usernameField.exists && usernameField.isHittable, "Registration form should still be active")
// }
// MARK: - 5. Verification Screen Tests
func test09_registrationWithInvalidVerificationCode() {
let username = testUsername
let email = testEmail
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
dismissKeyboard()
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
// Wait for verification screen
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen")
// Enter INVALID code
let codeField = verificationCodeField()
XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable)
dismissKeyboard()
codeField.tap()
codeField.typeText("000000") // Wrong code
let verifyButton = verificationButton()
dismissKeyboard()
verifyButton.tap()
// STRICT: Error message must appear
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'error' OR label CONTAINS[c] 'incorrect' OR label CONTAINS[c] 'wrong'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 5), "Error message MUST appear for invalid verification code")
}
func test10_verificationCodeFieldValidation() {
let username = testUsername
let email = testEmail
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
dismissKeyboard()
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
XCTAssertTrue(waitForVerificationScreen(timeout: 10))
// Enter incomplete code (only 3 digits)
let codeField = verificationCodeField()
XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable)
dismissKeyboard()
codeField.tap()
codeField.typeText("123") // Incomplete
let verifyButton = verificationButton()
// Button might be disabled with incomplete code
if verifyButton.isEnabled {
dismissKeyboard()
verifyButton.tap()
}
// STRICT: Must still be on verification screen
XCTAssertTrue(codeField.exists && codeField.isHittable, "Must remain on verification screen with incomplete code")
// NEGATIVE CHECK: Should NOT have navigated to main app
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
if residencesTab.exists {
XCTAssertFalse(residencesTab.isHittable, "Tab bar MUST NOT be accessible with incomplete verification")
}
}
func test11_appRelaunchWithUnverifiedUser() {
// This test verifies the fix for: user kills app on verification screen, relaunches, should see verification again
let username = testUsername
let email = testEmail
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
dismissKeyboard()
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
// Wait for verification screen
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must reach verification screen")
// Simulate app kill and relaunch (terminate and launch)
app.terminate()
app.launch()
// STRICT: After relaunch, unverified user MUST see verification screen, NOT main app
let authCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
let onboardingCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
let loginScreen = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
let tabBar = app.tabBars.firstMatch
// Wait for app to settle
_ = authCodeFieldAfterRelaunch.waitForExistence(timeout: 10)
|| onboardingCodeFieldAfterRelaunch.waitForExistence(timeout: 10)
|| loginScreen.waitForExistence(timeout: 10)
// User should either be on verification screen OR login screen (if token expired)
// They should NEVER be on main app with unverified email
if tabBar.exists && tabBar.isHittable {
// If tab bar is accessible, that's a FAILURE - unverified user should not access main app
XCTFail("CRITICAL: Unverified user should NOT have access to main app after relaunch. Tab bar is hittable!")
}
// Acceptable states: verification screen OR login screen
let onVerificationScreen =
(authCodeFieldAfterRelaunch.exists && authCodeFieldAfterRelaunch.isHittable)
|| (onboardingCodeFieldAfterRelaunch.exists && onboardingCodeFieldAfterRelaunch.isHittable)
let onLoginScreen = loginScreen.exists && loginScreen.isHittable
XCTAssertTrue(onVerificationScreen || onLoginScreen,
"After relaunch, unverified user must be on verification screen or login screen, NOT main app")
// Cleanup
if onVerificationScreen {
let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch
if logoutButton.exists && logoutButton.isHittable {
dismissKeyboard()
logoutButton.tap()
}
}
}
func test12_logoutFromVerificationScreen() {
let username = testUsername
let email = testEmail
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
dismissKeyboard()
app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
// Wait for verification screen
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen")
// STRICT: Logout button must exist and be tappable
let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch
XCTAssertTrue(logoutButton.waitForExistence(timeout: 5), "Logout button MUST exist on verification screen")
XCTAssertTrue(logoutButton.isHittable, "Logout button MUST be tappable on verification screen")
dismissKeyboard()
logoutButton.tap()
// STRICT: Verification screen must disappear
let codeField = verificationCodeField()
XCTAssertTrue(waitForElementToDisappear(codeField, timeout: 5), "Verification screen must disappear after logout")
// STRICT: Must return to login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Must return to login screen after logout")
XCTAssertTrue(welcomeText.isHittable, "Login screen must be interactive")
// NEGATIVE CHECK: Verification screen elements should be gone
XCTAssertFalse(codeField.exists, "Verification code field should NOT exist after logout")
}
}
// MARK: - XCUIElement Extension
extension XCUIElement {
var hasKeyboardFocus: Bool {
return (value(forKey: "hasKeyboardFocus") as? Bool) ?? false
}
}

View File

@@ -0,0 +1,140 @@
import XCTest
/// Authentication flow tests
/// Based on working SimpleLoginTest pattern
final class Suite2_AuthenticationTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
override func setUpWithError() throws {
try super.setUpWithError()
ensureLoggedOut()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
}
// MARK: - Helper Methods
private func ensureLoggedOut() {
UITestHelpers.ensureLoggedOut(app: app)
}
private func login(username: String, password: String) {
UITestHelpers.login(app: app, username: username, password: password)
}
// MARK: - 1. Error/Validation Tests
func test01_loginWithInvalidCredentials() {
// Given: User is on login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.exists, "Should be on login screen")
// When: User logs in with invalid credentials
login(username: "wronguser", password: "wrongpass")
// Then: User should see error message and stay on login screen
sleep(3) // Wait for API response
// Should still be on login screen
XCTAssertTrue(welcomeText.exists, "Should still be on login screen")
// Sign In button should still be visible (not logged in)
let signInButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign In'")).firstMatch
XCTAssertTrue(signInButton.exists, "Should still see Sign In button")
}
// MARK: - 2. Creation Tests (Login/Session)
func test02_loginWithValidCredentials() {
// Given: User is on login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.exists, "Should be on login screen")
// When: User logs in with valid credentials
login(username: "testuser", password: "TestPass123!")
// Then: User should see main tab view
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
let didNavigate = residencesTab.waitForExistence(timeout: 10)
XCTAssertTrue(didNavigate, "Should navigate to main app after successful login")
}
// MARK: - 3. View/UI Tests
func test03_passwordVisibilityToggle() {
// Given: User is on login screen
let passwordField = app.secureTextFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'password'")).firstMatch
XCTAssertTrue(passwordField.waitForExistence(timeout: 5), "Password field should exist")
// When: User types password
passwordField.tap()
passwordField.typeText("secret123")
// Then: Find and tap the eye icon (visibility toggle)
let eyeButton = app.buttons[AccessibilityIdentifiers.Authentication.passwordVisibilityToggle].firstMatch
XCTAssertTrue(eyeButton.waitForExistence(timeout: 5), "Password visibility toggle button must exist")
eyeButton.tap()
sleep(1)
// Password should now be visible in a regular text field
let visiblePasswordField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'password'")).firstMatch
XCTAssertTrue(visiblePasswordField.exists, "Password should be visible after toggle")
}
// MARK: - 4. Navigation Tests
func test04_navigationToSignUp() {
// Given: User is on login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.exists, "Should be on login screen")
// When: User taps Sign Up button
let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch
XCTAssertTrue(signUpButton.exists, "Sign Up button should exist")
signUpButton.tap()
// Then: Registration screen should appear
sleep(2)
let registerButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Register' OR label CONTAINS[c] 'Create Account'")).firstMatch
XCTAssertTrue(registerButton.waitForExistence(timeout: 5), "Should navigate to registration screen")
}
func test05_forgotPasswordNavigation() {
// Given: User is on login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.exists, "Should be on login screen")
// When: User taps Forgot Password button
let forgotPasswordButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Forgot Password'")).firstMatch
XCTAssertTrue(forgotPasswordButton.exists, "Forgot Password button should exist")
forgotPasswordButton.tap()
// Then: Password reset screen should appear
sleep(2)
// Look for email field or reset button
let emailField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch
let resetButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Reset' OR label CONTAINS[c] 'Send'")).firstMatch
let passwordResetScreenAppeared = emailField.exists || resetButton.exists
XCTAssertTrue(passwordResetScreenAppeared, "Should navigate to password reset screen")
}
// MARK: - 5. Delete/Logout Tests
func test06_logout() {
// Given: User is logged in
login(username: "testuser", password: "TestPass123!")
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.waitForExistence(timeout: 10), "Should be logged in")
// When: User logs out
UITestHelpers.logout(app: app)
// Then: User should be back on login screen (verified by UITestHelpers.logout)
}
}

View File

@@ -0,0 +1,238 @@
import XCTest
/// Residence management tests
/// Based on working SimpleLoginTest pattern
///
/// Test Order (logical dependencies):
/// 1. View/UI tests (work with empty list)
/// 2. Navigation tests (don't create data)
/// 3. Cancel test (opens form but doesn't save)
/// 4. Creation tests (creates data)
/// 5. Tests that depend on created data (view details)
final class Suite3_ResidenceTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
override func setUpWithError() throws {
try super.setUpWithError()
ensureLoggedIn()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
}
// MARK: - Helper Methods
private func ensureLoggedIn() {
UITestHelpers.ensureLoggedIn(app: app)
// Navigate to Residences tab
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
if residencesTab.exists {
residencesTab.tap()
sleep(1)
}
}
private func navigateToResidencesTab() {
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
if !residencesTab.isSelected {
residencesTab.tap()
sleep(1)
}
}
// MARK: - 1. View/UI Tests (work with empty list)
func test01_viewResidencesList() {
// Given: User is logged in and on Residences tab
navigateToResidencesTab()
// Then: Should see residences list header (must exist even if empty)
let residencesHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Your Properties' OR label CONTAINS[c] 'My Properties' OR label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesHeader.waitForExistence(timeout: 5), "Residences list screen must be visible")
// Add button must exist
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
XCTAssertTrue(addButton.exists, "Add residence button must exist")
}
// MARK: - 2. Navigation Tests (don't create data)
func test02_navigateToAddResidence() {
// Given: User is on Residences tab
navigateToResidencesTab()
// When: User taps add residence button (using accessibility identifier to avoid wrong button)
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
XCTAssertTrue(addButton.waitForExistence(timeout: 5), "Add residence button should exist")
addButton.tap()
// Then: Should show add residence form with all required fields
sleep(2)
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Property Name' OR placeholderValue CONTAINS[c] 'Name'")).firstMatch
XCTAssertTrue(nameField.exists, "Name field should exist in residence form")
// Verify property type picker exists
let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch
XCTAssertTrue(propertyTypePicker.exists, "Property type picker should exist in residence form")
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
XCTAssertTrue(saveButton.exists, "Save button should exist in residence form")
}
func test03_navigationBetweenTabs() {
// Given: User is on Residences tab
navigateToResidencesTab()
// When: User navigates to Tasks tab
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist")
tasksTab.tap()
sleep(1)
// Then: Should be on Tasks tab
XCTAssertTrue(tasksTab.isSelected, "Should be on Tasks tab")
// When: User navigates back to Residences
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
residencesTab.tap()
sleep(1)
// Then: Should be back on Residences tab
XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab")
}
// MARK: - 3. Cancel Test (opens form but doesn't save)
func test04_cancelResidenceCreation() {
// Given: User is on add residence form
navigateToResidencesTab()
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
addButton.tap()
sleep(2)
// When: User taps cancel
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
XCTAssertTrue(cancelButton.waitForExistence(timeout: 5), "Cancel button should exist")
cancelButton.tap()
// Then: Should return to residences list
sleep(1)
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.exists, "Should be back on residences list")
}
// MARK: - 4. Creation Tests
func test05_createResidenceWithMinimalData() {
// Given: User is on add residence form
navigateToResidencesTab()
// Use accessibility identifier to get the correct add button
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
XCTAssertTrue(addButton.exists, "Add residence button should exist")
addButton.tap()
sleep(2)
// When: Verify form loaded correctly
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Property Name' OR placeholderValue CONTAINS[c] 'Name'")).firstMatch
XCTAssertTrue(nameField.waitForExistence(timeout: 5), "Name field should appear - form did not load correctly!")
// Fill name field
let timestamp = Int(Date().timeIntervalSince1970)
let residenceName = "UITest Home \(timestamp)"
nameField.tap()
nameField.typeText(residenceName)
// Select property type (required field)
let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch
if propertyTypePicker.exists {
propertyTypePicker.tap()
sleep(2)
// After tapping picker, look for any selectable option
// Try common property types as buttons
if app.buttons["House"].exists {
app.buttons["House"].tap()
} else if app.buttons["Apartment"].exists {
app.buttons["Apartment"].tap()
} else if app.buttons["Condo"].exists {
app.buttons["Condo"].tap()
} else {
// If navigation style, try cells
let cells = app.cells
if cells.count > 1 {
cells.element(boundBy: 1).tap() // Skip first which might be "Select Type"
}
}
sleep(1)
}
// Fill address fields - MUST exist for residence
let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch
XCTAssertTrue(streetField.exists, "Street field should exist in residence form")
streetField.tap()
streetField.typeText("123 Test St")
let cityField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'City'")).firstMatch
XCTAssertTrue(cityField.exists, "City field should exist in residence form")
cityField.tap()
cityField.typeText("TestCity")
let stateField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'State'")).firstMatch
XCTAssertTrue(stateField.exists, "State field should exist in residence form")
stateField.tap()
stateField.typeText("TS")
let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Postal'")).firstMatch
XCTAssertTrue(postalField.exists, "Postal code field should exist in residence form")
postalField.tap()
postalField.typeText("12345")
// Scroll down to see more fields
app.swipeUp()
sleep(1)
// Save
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
XCTAssertTrue(saveButton.exists, "Save button should exist")
saveButton.tap()
// Then: Should return to residences list and verify residence was created
sleep(3) // Wait for save to complete
// First check we're back on the list
let residencesList = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Your Properties' OR label CONTAINS 'My Properties'")).firstMatch
XCTAssertTrue(residencesList.waitForExistence(timeout: 10), "Should return to residences list after saving")
// CRITICAL: Verify the residence actually appears in the list
let newResidence = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(residenceName)'")).firstMatch
XCTAssertTrue(newResidence.waitForExistence(timeout: 10), "New residence '\(residenceName)' should appear in the list - network call may have failed!")
}
// MARK: - 5. Tests That Depend on Created Data
func test06_viewResidenceDetails() {
// Given: User is on Residences tab with at least one residence
// This test requires testCreateResidenceWithMinimalData to have run first
navigateToResidencesTab()
sleep(2)
// Find a residence card by looking for UITest Home text
let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'UITest Home' OR label CONTAINS 'Test'")).firstMatch
XCTAssertTrue(residenceCard.waitForExistence(timeout: 5), "At least one residence must exist - run testCreateResidenceWithMinimalData first")
// When: User taps on the residence
residenceCard.tap()
sleep(2)
// Then: Should show residence details screen with edit/delete buttons
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete'")).firstMatch
XCTAssertTrue(editButton.exists || deleteButton.exists, "Residence details screen must show with edit or delete button")
}
}

View File

@@ -0,0 +1,670 @@
import XCTest
/// Comprehensive residence testing suite covering all scenarios, edge cases, and variations
/// This test suite is designed to be bulletproof and catch regressions early
///
/// Test Order (least to most complex):
/// 1. Error/incomplete data tests
/// 2. Creation tests
/// 3. Edit/update tests
/// 4. Delete/remove tests (none currently)
/// 5. Navigation/view tests
/// 6. Performance tests
final class Suite4_ComprehensiveResidenceTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
// Test data tracking
var createdResidenceNames: [String] = []
override func setUpWithError() throws {
try super.setUpWithError()
// Ensure user is logged in
UITestHelpers.ensureLoggedIn(app: app)
// Navigate to Residences tab
navigateToResidencesTab()
}
override func tearDownWithError() throws {
createdResidenceNames.removeAll()
try super.tearDownWithError()
}
// MARK: - Helper Methods
private func navigateToResidencesTab() {
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
if residencesTab.waitForExistence(timeout: 5) {
if !residencesTab.isSelected {
residencesTab.tap()
sleep(3)
}
}
}
private func openResidenceForm() -> Bool {
let addButton = findAddResidenceButton()
guard addButton.exists && addButton.isEnabled else { return false }
addButton.tap()
sleep(3)
// Verify form opened
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
return nameField.waitForExistence(timeout: 5)
}
private func findAddResidenceButton() -> XCUIElement {
sleep(2)
let addButtonById = app.buttons[AccessibilityIdentifiers.Residence.addButton]
if addButtonById.exists && addButtonById.isEnabled {
return addButtonById
}
let navBarButtons = app.navigationBars.buttons
for i in 0..<navBarButtons.count {
let button = navBarButtons.element(boundBy: i)
if button.label == "plus" || button.label.contains("Add") {
if button.isEnabled {
return button
}
}
}
return addButtonById
}
private func fillTextField(placeholder: String, text: String) {
let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch
if field.exists {
field.tap()
field.typeText(text)
}
}
private func selectPropertyType(type: String) {
let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch
if propertyTypePicker.exists {
propertyTypePicker.tap()
sleep(1)
// Try to find and tap the type option
let typeButton = app.buttons[type]
if typeButton.exists {
typeButton.tap()
sleep(1)
} else {
// Try cells if it's a navigation style picker
let cells = app.cells
for i in 0..<cells.count {
let cell = cells.element(boundBy: i)
if cell.staticTexts[type].exists {
cell.tap()
sleep(1)
break
}
}
}
}
}
private func createResidence(
name: String,
propertyType: String = "House",
street: String = "123 Test St",
city: String = "TestCity",
state: String = "TS",
postal: String = "12345",
scrollBeforeAddress: Bool = true
) -> Bool {
guard openResidenceForm() else { return false }
// Fill name
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
nameField.tap()
nameField.typeText(name)
// Select property type
selectPropertyType(type: propertyType)
// Scroll to address section
if scrollBeforeAddress {
app.swipeUp()
sleep(1)
}
// Fill address fields
fillTextField(placeholder: "Street", text: street)
fillTextField(placeholder: "City", text: city)
fillTextField(placeholder: "State", text: state)
fillTextField(placeholder: "Postal", text: postal)
// Save
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
guard saveButton.exists else { return false }
saveButton.tap()
sleep(4) // Wait for API call
// Track created residence
createdResidenceNames.append(name)
return true
}
private func findResidence(name: String) -> XCUIElement {
return app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch
}
// MARK: - 1. Error/Validation Tests
func test01_cannotCreateResidenceWithEmptyName() {
guard openResidenceForm() else {
XCTFail("Failed to open residence form")
return
}
// Leave name empty, fill only address
app.swipeUp()
sleep(1)
fillTextField(placeholder: "Street", text: "123 Test St")
fillTextField(placeholder: "City", text: "TestCity")
fillTextField(placeholder: "State", text: "TS")
fillTextField(placeholder: "Postal", text: "12345")
// Scroll to save button if needed
app.swipeUp()
sleep(1)
// Save button should be disabled when name is empty
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
XCTAssertTrue(saveButton.exists, "Save button should exist")
XCTAssertFalse(saveButton.isEnabled, "Save button should be disabled when name is empty")
}
func test02_cancelResidenceCreation() {
guard openResidenceForm() else {
XCTFail("Failed to open residence form")
return
}
// Fill some data
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
nameField.tap()
nameField.typeText("This will be canceled")
// Tap cancel
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
XCTAssertTrue(cancelButton.exists, "Cancel button should exist")
cancelButton.tap()
sleep(2)
// Should be back on residences list
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.exists, "Should be back on residences list")
// Residence should not exist
let residence = findResidence(name: "This will be canceled")
XCTAssertFalse(residence.exists, "Canceled residence should not exist")
}
// MARK: - 2. Creation Tests
func test03_createResidenceWithMinimalData() {
let timestamp = Int(Date().timeIntervalSince1970)
let residenceName = "Minimal Home \(timestamp)"
let success = createResidence(name: residenceName)
XCTAssertTrue(success, "Should successfully create residence with minimal data")
let residenceInList = findResidence(name: residenceName)
XCTAssertTrue(residenceInList.waitForExistence(timeout: 10), "Residence should appear in list")
}
func test04_createResidenceWithAllPropertyTypes() {
let timestamp = Int(Date().timeIntervalSince1970)
let propertyTypes = ["House", "Apartment", "Condo"]
for (index, type) in propertyTypes.enumerated() {
let residenceName = "\(type) Test \(timestamp)_\(index)"
let success = createResidence(name: residenceName, propertyType: type)
XCTAssertTrue(success, "Should create \(type) residence")
navigateToResidencesTab()
sleep(2)
}
// Verify all residences exist
for (index, type) in propertyTypes.enumerated() {
let residenceName = "\(type) Test \(timestamp)_\(index)"
let residence = findResidence(name: residenceName)
XCTAssertTrue(residence.exists, "\(type) residence should exist in list")
}
}
func test05_createMultipleResidencesInSequence() {
let timestamp = Int(Date().timeIntervalSince1970)
for i in 1...3 {
let residenceName = "Sequential Home \(i) - \(timestamp)"
let success = createResidence(name: residenceName)
XCTAssertTrue(success, "Should create residence \(i)")
navigateToResidencesTab()
sleep(2)
}
// Verify all residences exist
for i in 1...3 {
let residenceName = "Sequential Home \(i) - \(timestamp)"
let residence = findResidence(name: residenceName)
XCTAssertTrue(residence.exists, "Residence \(i) should exist in list")
}
}
func test06_createResidenceWithVeryLongName() {
let timestamp = Int(Date().timeIntervalSince1970)
let longName = "This is an extremely long residence name that goes on and on and on to test how the system handles very long text input in the name field \(timestamp)"
let success = createResidence(name: longName)
XCTAssertTrue(success, "Should handle very long names")
// Verify it appears (may be truncated in display)
let residence = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'extremely long residence'")).firstMatch
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Long name residence should exist")
}
func test07_createResidenceWithSpecialCharacters() {
let timestamp = Int(Date().timeIntervalSince1970)
let specialName = "Special !@#$%^&*() Home \(timestamp)"
let success = createResidence(name: specialName)
XCTAssertTrue(success, "Should handle special characters")
let residence = findResidence(name: "Special")
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with special chars should exist")
}
func test08_createResidenceWithEmojis() {
let timestamp = Int(Date().timeIntervalSince1970)
let emojiName = "Beach House \(timestamp)"
let success = createResidence(name: emojiName)
XCTAssertTrue(success, "Should handle emojis")
let residence = findResidence(name: "Beach House")
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with emojis should exist")
}
func test09_createResidenceWithInternationalCharacters() {
let timestamp = Int(Date().timeIntervalSince1970)
let internationalName = "Chateau Montreal \(timestamp)"
let success = createResidence(name: internationalName)
XCTAssertTrue(success, "Should handle international characters")
let residence = findResidence(name: "Chateau")
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with international chars should exist")
}
func test10_createResidenceWithVeryLongAddress() {
let timestamp = Int(Date().timeIntervalSince1970)
let residenceName = "Long Address Home \(timestamp)"
let success = createResidence(
name: residenceName,
street: "123456789 Very Long Street Name That Goes On And On Boulevard Apartment Complex Unit 42B",
city: "VeryLongCityNameThatTestsTheLimit",
state: "CA",
postal: "12345-6789"
)
XCTAssertTrue(success, "Should handle very long addresses")
let residence = findResidence(name: residenceName)
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with long address should exist")
}
// MARK: - 3. Edit/Update Tests
func test11_editResidenceName() {
let timestamp = Int(Date().timeIntervalSince1970)
let originalName = "Original Name \(timestamp)"
let newName = "Edited Name \(timestamp)"
// Create residence
guard createResidence(name: originalName) else {
XCTFail("Failed to create residence")
return
}
navigateToResidencesTab()
sleep(2)
// Find and tap residence
let residence = findResidence(name: originalName)
XCTAssertTrue(residence.waitForExistence(timeout: 5), "Residence should exist")
residence.tap()
sleep(2)
// Tap edit button
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
if editButton.exists {
editButton.tap()
sleep(2)
// Edit name
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
if nameField.exists {
let element = app/*@START_MENU_TOKEN@*/.textFields["ResidenceForm.NameField"]/*[[".otherElements",".textFields[\"Original Name 1764809003\"]",".textFields[\"Property Name\"]",".textFields[\"ResidenceForm.NameField\"]"],[[[-1,3],[-1,2],[-1,1],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch
element.tap()
element.tap()
app/*@START_MENU_TOKEN@*/.menuItems["Select All"]/*[[".menuItems.containing(.staticText, identifier: \"Select All\")",".collectionViews.menuItems[\"Select All\"]",".menuItems[\"Select All\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
nameField.typeText(newName)
// Save
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
if saveButton.exists {
saveButton.tap()
sleep(3)
// Track new name
createdResidenceNames.append(newName)
// Verify new name appears
navigateToResidencesTab()
sleep(2)
let updatedResidence = findResidence(name: newName)
XCTAssertTrue(updatedResidence.exists, "Residence should show updated name")
}
}
}
}
func test12_updateAllResidenceFields() {
let timestamp = Int(Date().timeIntervalSince1970)
let originalName = "Update All Fields \(timestamp)"
let newName = "All Fields Updated \(timestamp)"
let newStreet = "999 Updated Avenue"
let newCity = "NewCity"
let newState = "NC"
let newPostal = "99999"
// Create residence with initial values
guard createResidence(name: originalName, street: "123 Old St", city: "OldCity", state: "OC", postal: "11111") else {
XCTFail("Failed to create residence")
return
}
navigateToResidencesTab()
sleep(2)
// Find and tap residence
let residence = findResidence(name: originalName)
XCTAssertTrue(residence.waitForExistence(timeout: 5), "Residence should exist")
residence.tap()
sleep(2)
// Tap edit button
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
XCTAssertTrue(editButton.exists, "Edit button should exist")
editButton.tap()
sleep(2)
// Update name
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
XCTAssertTrue(nameField.exists, "Name field should exist")
nameField.tap()
nameField.doubleTap()
sleep(1)
if app.buttons["Select All"].exists {
app.buttons["Select All"].tap()
sleep(1)
}
nameField.typeText(newName)
// Update property type (if available)
let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch
if propertyTypePicker.exists {
propertyTypePicker.tap()
sleep(1)
// Select Condo
let condoOption = app.buttons["Condo"]
if condoOption.exists {
condoOption.tap()
sleep(1)
} else {
// Try cells navigation
let cells = app.cells
for i in 0..<cells.count {
let cell = cells.element(boundBy: i)
if cell.staticTexts["Condo"].exists {
cell.tap()
sleep(1)
break
}
}
}
}
// Scroll to address fields
app.swipeUp()
sleep(1)
// Update street
let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch
if streetField.exists {
streetField.tap()
streetField.doubleTap()
sleep(1)
if app.buttons["Select All"].exists {
app.buttons["Select All"].tap()
sleep(1)
}
streetField.typeText(newStreet)
}
// Update city
let cityField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'City'")).firstMatch
if cityField.exists {
cityField.tap()
cityField.doubleTap()
sleep(1)
if app.buttons["Select All"].exists {
app.buttons["Select All"].tap()
sleep(1)
}
cityField.typeText(newCity)
}
// Update state
let stateField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'State'")).firstMatch
if stateField.exists {
stateField.tap()
stateField.doubleTap()
sleep(1)
if app.buttons["Select All"].exists {
app.buttons["Select All"].tap()
sleep(1)
}
stateField.typeText(newState)
}
// Update postal code
let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Zip'")).firstMatch
if postalField.exists {
postalField.tap()
postalField.doubleTap()
sleep(1)
if app.buttons["Select All"].exists {
app.buttons["Select All"].tap()
sleep(1)
}
postalField.typeText(newPostal)
}
// Scroll to save button
app.swipeUp()
sleep(1)
// Save
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
XCTAssertTrue(saveButton.exists, "Save button should exist")
saveButton.tap()
sleep(4)
// Track new name
createdResidenceNames.append(newName)
// Verify updated residence appears in list with new name
navigateToResidencesTab()
sleep(2)
let updatedResidence = findResidence(name: newName)
XCTAssertTrue(updatedResidence.exists, "Residence should show updated name in list")
// Tap on residence to verify details were updated
updatedResidence.tap()
sleep(2)
// Verify updated address appears in detail view
let streetText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newStreet)'")).firstMatch
XCTAssertTrue(streetText.exists || true, "Updated street should be visible in detail view")
let cityText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newCity)'")).firstMatch
XCTAssertTrue(cityText.exists || true, "Updated city should be visible in detail view")
let postalText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newPostal)'")).firstMatch
XCTAssertTrue(postalText.exists || true, "Updated postal code should be visible in detail view")
// Verify property type was updated to Condo
let condoBadge = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Condo'")).firstMatch
XCTAssertTrue(condoBadge.exists || true, "Updated property type should be visible (if shown in detail)")
}
// MARK: - 4. View/Navigation Tests
func test13_viewResidenceDetails() {
let timestamp = Int(Date().timeIntervalSince1970)
let residenceName = "Detail View Test \(timestamp)"
// Create residence
guard createResidence(name: residenceName) else {
XCTFail("Failed to create residence")
return
}
navigateToResidencesTab()
sleep(2)
// Tap on residence
let residence = findResidence(name: residenceName)
XCTAssertTrue(residence.exists, "Residence should exist")
residence.tap()
sleep(3)
// Verify detail view appears with edit button or tasks section
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch
XCTAssertTrue(editButton.exists || tasksSection.exists, "Detail view should show with edit button or tasks section")
}
func test14_navigateFromResidencesToOtherTabs() {
// From Residences tab
navigateToResidencesTab()
// Navigate to Tasks
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist")
tasksTab.tap()
sleep(1)
XCTAssertTrue(tasksTab.isSelected, "Should be on Tasks tab")
// Navigate back to Residences
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
residencesTab.tap()
sleep(1)
XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab")
// Navigate to Contractors
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist")
contractorsTab.tap()
sleep(1)
XCTAssertTrue(contractorsTab.isSelected, "Should be on Contractors tab")
// Back to Residences
residencesTab.tap()
sleep(1)
XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab again")
}
func test15_refreshResidencesList() {
navigateToResidencesTab()
sleep(2)
// Pull to refresh (if implemented) or use refresh button
let refreshButton = app.navigationBars.buttons.containing(NSPredicate(format: "label CONTAINS 'arrow.clockwise' OR label CONTAINS 'refresh'")).firstMatch
if refreshButton.exists {
refreshButton.tap()
sleep(3)
}
// Verify we're still on residences tab
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.isSelected, "Should still be on Residences tab after refresh")
}
// MARK: - 5. Persistence Tests
func test16_residencePersistsAfterBackgroundingApp() {
let timestamp = Int(Date().timeIntervalSince1970)
let residenceName = "Persistence Test \(timestamp)"
// Create residence
guard createResidence(name: residenceName) else {
XCTFail("Failed to create residence")
return
}
navigateToResidencesTab()
sleep(2)
// Verify residence exists
var residence = findResidence(name: residenceName)
XCTAssertTrue(residence.exists, "Residence should exist before backgrounding")
// Background and reactivate app
XCUIDevice.shared.press(.home)
sleep(2)
app.activate()
sleep(3)
// Navigate back to residences
navigateToResidencesTab()
sleep(2)
// Verify residence still exists
residence = findResidence(name: residenceName)
XCTAssertTrue(residence.exists, "Residence should persist after backgrounding app")
}
// MARK: - 6. Performance Tests
func test17_residenceListPerformance() {
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
navigateToResidencesTab()
sleep(2)
}
}
func test18_residenceCreationPerformance() {
let timestamp = Int(Date().timeIntervalSince1970)
measure(metrics: [XCTClockMetric()]) {
let residenceName = "Performance Test \(timestamp)_\(UUID().uuidString.prefix(8))"
_ = createResidence(name: residenceName)
}
}
}

View File

@@ -0,0 +1,375 @@
import XCTest
/// Task management tests
/// Uses UITestHelpers for consistent login/logout behavior
/// IMPORTANT: Tasks require at least one residence to exist
///
/// Test Order (least to most complex):
/// 1. Error/incomplete data tests
/// 2. Creation tests
/// 3. Edit/update tests
/// 4. Delete/remove tests (none currently)
/// 5. Navigation/view tests
final class Suite5_TaskTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
override func setUpWithError() throws {
try super.setUpWithError()
// Ensure user is logged in
UITestHelpers.ensureLoggedIn(app: app)
// CRITICAL: Ensure at least one residence exists
// Tasks are disabled if no residences exist
ensureResidenceExists()
// Now navigate to Tasks tab
navigateToTasksTab()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
}
// MARK: - Helper Methods
/// Ensures at least one residence exists (required for tasks to work)
private func ensureResidenceExists() {
// Navigate to Residences tab
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
if residencesTab.waitForExistence(timeout: 5) {
residencesTab.tap()
sleep(2)
// Check if we have any residences
// Look for the add button - if we see "Add a property" text or empty state, create one
let emptyStateText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties' OR label CONTAINS[c] 'No residences'")).firstMatch
if emptyStateText.exists {
// No residences exist, create a quick one
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
if addButton.waitForExistence(timeout: 5) {
addButton.tap()
sleep(2)
// Fill minimal required fields
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
if nameField.waitForExistence(timeout: 5) {
nameField.tap()
nameField.typeText("Test Home for Tasks")
// Scroll to address fields
app.swipeUp()
sleep(1)
// Fill required address fields
let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch
if streetField.exists {
streetField.tap()
streetField.typeText("123 Test St")
}
let cityField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'City'")).firstMatch
if cityField.exists {
cityField.tap()
cityField.typeText("TestCity")
}
let stateField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'State'")).firstMatch
if stateField.exists {
stateField.tap()
stateField.typeText("TS")
}
let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Zip'")).firstMatch
if postalField.exists {
postalField.tap()
postalField.typeText("12345")
}
// Save
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
if saveButton.exists {
saveButton.tap()
sleep(3) // Wait for save to complete
}
}
}
}
}
}
private func navigateToTasksTab() {
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
if tasksTab.waitForExistence(timeout: 5) {
if !tasksTab.isSelected {
tasksTab.tap()
sleep(3) // Give it time to load
}
}
}
/// Finds the Add Task button using multiple strategies
/// The button exists in two places:
/// 1. Toolbar (always visible when residences exist)
/// 2. Empty state (visible when no tasks exist)
private func findAddTaskButton() -> XCUIElement {
sleep(2) // Wait for screen to fully render
// Strategy 1: Try accessibility identifier
let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
if addButtonById.exists && addButtonById.isEnabled {
return addButtonById
}
// Strategy 2: Look for toolbar add button (navigation bar plus button)
let navBarButtons = app.navigationBars.buttons
for i in 0..<navBarButtons.count {
let button = navBarButtons.element(boundBy: i)
if button.label == "plus" || button.label.contains("Add") {
if button.isEnabled {
return button
}
}
}
// Strategy 3: Try finding "Add Task" button in empty state by text
let emptyStateButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task'")).firstMatch
if emptyStateButton.exists && emptyStateButton.isEnabled {
return emptyStateButton
}
// Strategy 4: Look for any enabled button with a plus icon
let allButtons = app.buttons
for i in 0..<min(allButtons.count, 20) { // Check first 20 buttons
let button = allButtons.element(boundBy: i)
if button.isEnabled && (button.label.contains("plus") || button.label.contains("Add")) {
return button
}
}
// Return the identifier one as fallback (will fail assertion if doesn't exist)
return addButtonById
}
// MARK: - 1. Error/Validation Tests
func test01_cancelTaskCreation() {
// Given: User is on add task form
navigateToTasksTab()
sleep(3)
let addButton = findAddTaskButton()
XCTAssertTrue(addButton.exists && addButton.isEnabled, "Add task button should exist and be enabled")
addButton.tap()
sleep(3)
// Verify form opened
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task form should open")
// When: User taps cancel
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
XCTAssertTrue(cancelButton.exists, "Cancel button should exist in task form")
cancelButton.tap()
sleep(2)
// Then: Should return to tasks list
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
XCTAssertTrue(tasksTab.exists, "Should be back on tasks list after cancel")
}
// MARK: - 2. View/List Tests
func test02_tasksTabExists() {
// Given: User is logged in
// When: User looks for Tasks tab
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
// Then: Tasks tab should exist
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist in main tab bar")
XCTAssertTrue(tasksTab.isSelected, "Tasks tab should be selected after navigation")
}
func test03_viewTasksList() {
// Given: User is on Tasks tab
navigateToTasksTab()
sleep(3)
// Then: Tasks screen should be visible
// Verify we're on the right screen by checking for the navigation title
let tasksTitle = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'All Tasks' OR label CONTAINS[c] 'Tasks'")).firstMatch
XCTAssertTrue(tasksTitle.waitForExistence(timeout: 5), "Tasks screen title should be visible")
}
func test04_addTaskButtonExists() {
// Given: User is on Tasks tab with at least one residence
navigateToTasksTab()
sleep(3)
// Then: Add task button should exist and be enabled
let addButton = findAddTaskButton()
XCTAssertTrue(addButton.exists, "Add task button should exist on Tasks screen")
XCTAssertTrue(addButton.isEnabled, "Add task button should be enabled when residence exists")
}
func test05_navigateToAddTask() {
// Given: User is on Tasks tab
navigateToTasksTab()
sleep(3)
// When: User taps add task button
let addButton = findAddTaskButton()
XCTAssertTrue(addButton.exists, "Add task button should exist")
XCTAssertTrue(addButton.isEnabled, "Add task button should be enabled")
addButton.tap()
sleep(3)
// Then: Should show add task form with required fields
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title' OR placeholderValue CONTAINS[c] 'Task'")).firstMatch
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task title field should appear in add form")
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
XCTAssertTrue(saveButton.exists, "Save button should exist in add task form")
}
// MARK: - 3. Creation Tests
func test06_createBasicTask() {
// Given: User is on Tasks tab
navigateToTasksTab()
sleep(3)
// When: User taps add task button
let addButton = findAddTaskButton()
XCTAssertTrue(addButton.exists && addButton.isEnabled, "Add task button should exist and be enabled")
addButton.tap()
sleep(3)
// Verify task form loaded
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task title field should appear")
// Fill in task title with unique timestamp
let timestamp = Int(Date().timeIntervalSince1970)
let taskTitle = "UITest Task \(timestamp)"
titleField.tap()
titleField.typeText(taskTitle)
// Scroll down to find and fill description
app.swipeUp()
sleep(1)
let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch
if descField.exists {
descField.tap()
descField.typeText("Test task")
}
// Scroll to find Save button
app.swipeUp()
sleep(1)
// When: User taps save
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
XCTAssertTrue(saveButton.exists, "Save button should exist")
saveButton.tap()
// Then: Should return to tasks list
sleep(5) // Wait for API call to complete
// Verify we're back on tasks list by checking tab exists
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
XCTAssertTrue(tasksTab.exists, "Should be back on tasks list after saving")
// Verify task appears in the list (may be in kanban columns)
let newTask = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(taskTitle)'")).firstMatch
XCTAssertTrue(newTask.waitForExistence(timeout: 10), "New task '\(taskTitle)' should appear in the list")
}
// MARK: - 4. View Details Tests
func test07_viewTaskDetails() {
// Given: User is on Tasks tab and at least one task exists
navigateToTasksTab()
sleep(3)
// Look for any task in the list
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'UITest Task' OR label CONTAINS 'Test'")).firstMatch
if !taskCard.waitForExistence(timeout: 5) {
// No task found - skip this test
print("No tasks found - run testCreateBasicTask first")
return
}
// When: User taps on a task
taskCard.tap()
sleep(2)
// Then: Should show task details screen
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
let completeButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Complete' OR label CONTAINS[c] 'Mark'")).firstMatch
let backButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Back' OR label CONTAINS[c] 'Tasks'")).firstMatch
let detailScreenVisible = editButton.exists || completeButton.exists || backButton.exists
XCTAssertTrue(detailScreenVisible, "Task details screen should show with action buttons")
}
// MARK: - 5. Navigation Tests
func test08_navigateToContractors() {
// Given: User is on Tasks tab
navigateToTasksTab()
sleep(1)
// When: User taps Contractors tab
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
XCTAssertTrue(contractorsTab.waitForExistence(timeout: 5), "Contractors tab should exist")
contractorsTab.tap()
sleep(1)
// Then: Should be on Contractors tab
XCTAssertTrue(contractorsTab.isSelected, "Contractors tab should be selected")
}
func test09_navigateToDocuments() {
// Given: User is on Tasks tab
navigateToTasksTab()
sleep(1)
// When: User taps Documents tab
let documentsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents'")).firstMatch
XCTAssertTrue(documentsTab.waitForExistence(timeout: 5), "Documents tab should exist")
documentsTab.tap()
sleep(1)
// Then: Should be on Documents tab
XCTAssertTrue(documentsTab.isSelected, "Documents tab should be selected")
}
func test10_navigateBetweenTabs() {
// Given: User is on Tasks tab
navigateToTasksTab()
sleep(1)
// When: User navigates to Residences tab
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
residencesTab.tap()
sleep(1)
// Then: Should be on Residences tab
XCTAssertTrue(residencesTab.isSelected, "Should be on Residences tab")
// When: User navigates back to Tasks
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
tasksTab.tap()
sleep(2)
// Then: Should be back on Tasks tab
XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab")
}
}

View File

@@ -0,0 +1,655 @@
import XCTest
/// Comprehensive task testing suite covering all scenarios, edge cases, and variations
/// This test suite is designed to be bulletproof and catch regressions early
///
/// Test Order (least to most complex):
/// 1. Error/incomplete data tests
/// 2. Creation tests
/// 3. Edit/update tests
/// 4. Delete/remove tests (none currently)
/// 5. Navigation/view tests
/// 6. Performance tests
final class Suite6_ComprehensiveTaskTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
// Test data tracking
var createdTaskTitles: [String] = []
override func setUpWithError() throws {
try super.setUpWithError()
// Ensure user is logged in
UITestHelpers.ensureLoggedIn(app: app)
// CRITICAL: Ensure at least one residence exists
ensureResidenceExists()
// Navigate to Tasks tab
navigateToTasksTab()
}
override func tearDownWithError() throws {
createdTaskTitles.removeAll()
try super.tearDownWithError()
}
// MARK: - Helper Methods
/// Ensures at least one residence exists (required for tasks to work)
private func ensureResidenceExists() {
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
if residencesTab.waitForExistence(timeout: 5) {
residencesTab.tap()
sleep(2)
let emptyStateText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties' OR label CONTAINS[c] 'No residences'")).firstMatch
if emptyStateText.exists {
createTestResidence()
}
}
}
private func createTestResidence() {
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
guard addButton.waitForExistence(timeout: 5) else { return }
addButton.tap()
sleep(2)
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
guard nameField.waitForExistence(timeout: 5) else { return }
nameField.tap()
nameField.typeText("Test Home for Comprehensive Tasks")
app.swipeUp()
sleep(1)
fillField(placeholder: "Street", text: "123 Test St")
fillField(placeholder: "City", text: "TestCity")
fillField(placeholder: "State", text: "TS")
fillField(placeholder: "Postal", text: "12345")
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
if saveButton.exists {
saveButton.tap()
sleep(3)
}
}
private func navigateToTasksTab() {
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
if tasksTab.waitForExistence(timeout: 5) {
if !tasksTab.isSelected {
tasksTab.tap()
sleep(3)
}
}
}
private func openTaskForm() -> Bool {
let addButton = findAddTaskButton()
guard addButton.exists && addButton.isEnabled else { return false }
addButton.tap()
sleep(3)
// Verify form opened
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
return titleField.waitForExistence(timeout: 5)
}
private func findAddTaskButton() -> XCUIElement {
sleep(2)
let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
if addButtonById.exists && addButtonById.isEnabled {
return addButtonById
}
let navBarButtons = app.navigationBars.buttons
for i in 0..<navBarButtons.count {
let button = navBarButtons.element(boundBy: i)
if button.label == "plus" || button.label.contains("Add") {
if button.isEnabled {
return button
}
}
}
return addButtonById
}
private func fillField(placeholder: String, text: String) {
let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch
if field.exists {
field.tap()
field.typeText(text)
}
}
private func selectPicker(label: String, option: String) {
let picker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(label)'")).firstMatch
if picker.exists {
picker.tap()
sleep(1)
// Try to find and tap the option
let optionButton = app.buttons[option]
if optionButton.exists {
optionButton.tap()
sleep(1)
}
}
}
private func createTask(
title: String,
description: String? = nil,
scrollToFindFields: Bool = true
) -> Bool {
guard openTaskForm() else { return false }
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
titleField.tap()
titleField.typeText(title)
if let desc = description {
if scrollToFindFields { app.swipeUp(); sleep(1) }
let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch
if descField.exists {
descField.tap()
descField.typeText(desc)
}
}
// Scroll to Save button
app.swipeUp()
sleep(1)
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
guard saveButton.exists else { return false }
saveButton.tap()
sleep(4) // Wait for API call
// Track created task
createdTaskTitles.append(title)
return true
}
private func findTask(title: String) -> XCUIElement {
return app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch
}
private func deleteAllTestTasks() {
for title in createdTaskTitles {
let task = findTask(title: title)
if task.exists {
task.tap()
sleep(2)
// Try to find delete button
let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Cancel'")).firstMatch
if deleteButton.exists {
deleteButton.tap()
sleep(1)
// Confirm deletion
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Archive' OR label CONTAINS[c] 'Confirm'")).firstMatch
if confirmButton.exists {
confirmButton.tap()
sleep(2)
}
}
// Go back to list
let backButton = app.navigationBars.buttons.firstMatch
if backButton.exists {
backButton.tap()
sleep(1)
}
}
}
}
// MARK: - 1. Error/Validation Tests
func test01_cannotCreateTaskWithEmptyTitle() {
guard openTaskForm() else {
XCTFail("Failed to open task form")
return
}
// Leave title empty but fill other required fields
// Select category
let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch
if categoryPicker.exists {
app.staticTexts["Appliances"].firstMatch.tap()
app.buttons["Plumbing"].firstMatch.tap()
}
// Select frequency
let frequencyPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Frequency'")).firstMatch
if frequencyPicker.exists {
app.staticTexts["Once"].firstMatch.tap()
app.buttons["Once"].firstMatch.tap()
}
// Select priority
let priorityPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Priority'")).firstMatch
if priorityPicker.exists {
app.staticTexts["High"].firstMatch.tap()
app.buttons["Low"].firstMatch.tap()
}
// Select status
let statusPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Status'")).firstMatch
if statusPicker.exists {
app.staticTexts["Pending"].firstMatch.tap()
app.buttons["Pending"].firstMatch.tap()
}
// Scroll to save button
app.swipeUp()
sleep(1)
// Save button should be disabled when title is empty
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
XCTAssertTrue(saveButton.exists, "Save button should exist")
XCTAssertFalse(saveButton.isEnabled, "Save button should be disabled when title is empty")
}
func test02_cancelTaskCreation() {
guard openTaskForm() else {
XCTFail("Failed to open task form")
return
}
// Fill some data
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
titleField.tap()
titleField.typeText("This will be canceled")
// Tap cancel
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
XCTAssertTrue(cancelButton.exists, "Cancel button should exist")
cancelButton.tap()
sleep(2)
// Should be back on tasks list
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
XCTAssertTrue(tasksTab.exists, "Should be back on tasks list")
// Task should not exist
let task = findTask(title: "This will be canceled")
XCTAssertFalse(task.exists, "Canceled task should not exist")
}
// MARK: - 2. Creation Tests
func test03_createTaskWithMinimalData() {
let timestamp = Int(Date().timeIntervalSince1970)
let taskTitle = "Minimal Task \(timestamp)"
let success = createTask(title: taskTitle)
XCTAssertTrue(success, "Should successfully create task with minimal data")
let taskInList = findTask(title: taskTitle)
XCTAssertTrue(taskInList.waitForExistence(timeout: 10), "Task should appear in list")
}
func test04_createTaskWithAllFields() {
let timestamp = Int(Date().timeIntervalSince1970)
let taskTitle = "Complete Task \(timestamp)"
let description = "This is a comprehensive test task with all fields populated including a very detailed description."
let success = createTask(title: taskTitle, description: description)
XCTAssertTrue(success, "Should successfully create task with all fields")
let taskInList = findTask(title: taskTitle)
XCTAssertTrue(taskInList.waitForExistence(timeout: 10), "Complete task should appear in list")
}
func test05_createMultipleTasksInSequence() {
let timestamp = Int(Date().timeIntervalSince1970)
for i in 1...3 {
let taskTitle = "Sequential Task \(i) - \(timestamp)"
let success = createTask(title: taskTitle)
XCTAssertTrue(success, "Should create task \(i)")
navigateToTasksTab()
sleep(2)
}
// Verify all tasks exist
for i in 1...3 {
let taskTitle = "Sequential Task \(i) - \(timestamp)"
let task = findTask(title: taskTitle)
XCTAssertTrue(task.exists, "Task \(i) should exist in list")
}
}
func test06_createTaskWithVeryLongTitle() {
let timestamp = Int(Date().timeIntervalSince1970)
let longTitle = "This is an extremely long task title that goes on and on and on to test how the system handles very long text input in the title field \(timestamp)"
let success = createTask(title: longTitle)
XCTAssertTrue(success, "Should handle very long titles")
// Verify it appears (may be truncated in display)
let task = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'extremely long task title'")).firstMatch
XCTAssertTrue(task.waitForExistence(timeout: 10), "Long title task should exist")
}
func test07_createTaskWithSpecialCharacters() {
let timestamp = Int(Date().timeIntervalSince1970)
let specialTitle = "Special !@#$%^&*() Task \(timestamp)"
let success = createTask(title: specialTitle)
XCTAssertTrue(success, "Should handle special characters")
let task = findTask(title: "Special")
XCTAssertTrue(task.waitForExistence(timeout: 10), "Task with special chars should exist")
}
func test08_createTaskWithEmojis() {
let timestamp = Int(Date().timeIntervalSince1970)
let emojiTitle = "Fix Plumbing Task \(timestamp)"
let success = createTask(title: emojiTitle)
XCTAssertTrue(success, "Should handle emojis")
let task = findTask(title: "Fix Plumbing")
XCTAssertTrue(task.waitForExistence(timeout: 10), "Task with emojis should exist")
}
// MARK: - 3. Edit/Update Tests
func test09_editTaskTitle() {
let timestamp = Int(Date().timeIntervalSince1970)
let originalTitle = "Original Title \(timestamp)"
let newTitle = "Edited Title \(timestamp)"
// Create task
guard createTask(title: originalTitle) else {
XCTFail("Failed to create task")
return
}
navigateToTasksTab()
sleep(2)
// Find and tap task
let task = findTask(title: originalTitle)
XCTAssertTrue(task.waitForExistence(timeout: 5), "Task should exist")
task.tap()
sleep(2)
// Tap edit button
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
if editButton.exists {
editButton.tap()
sleep(2)
// Edit title
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
if titleField.exists {
titleField.tap()
// Clear existing text
titleField.doubleTap()
sleep(1)
app.buttons["Select All"].tap()
sleep(1)
titleField.typeText(newTitle)
// Save
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
if saveButton.exists {
saveButton.tap()
sleep(3)
// Track new title
createdTaskTitles.append(newTitle)
// Verify new title appears
navigateToTasksTab()
sleep(2)
let updatedTask = findTask(title: newTitle)
XCTAssertTrue(updatedTask.exists, "Task should show updated title")
}
}
}
}
func test10_updateAllTaskFields() {
let timestamp = Int(Date().timeIntervalSince1970)
let originalTitle = "Update All Fields \(timestamp)"
let newTitle = "All Fields Updated \(timestamp)"
let newDescription = "This task has been fully updated with all new values including description, category, priority, and status."
// Create task with initial values
guard createTask(title: originalTitle, description: "Original description") else {
XCTFail("Failed to create task")
return
}
navigateToTasksTab()
sleep(2)
// Find and tap task
let task = findTask(title: originalTitle)
XCTAssertTrue(task.waitForExistence(timeout: 5), "Task should exist")
task.tap()
sleep(2)
// Tap edit button
let editButton = app.staticTexts.matching(identifier: "Actions").element(boundBy: 0).firstMatch
XCTAssertTrue(editButton.exists, "Edit button should exist")
editButton.tap()
app.buttons["pencil"].firstMatch.tap()
sleep(2)
// Update title
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
XCTAssertTrue(titleField.exists, "Title field should exist")
titleField.tap()
sleep(1)
titleField.tap()
sleep(1)
app.menuItems["Select All"].tap()
sleep(1)
titleField.typeText(newTitle)
// Scroll to description
app.swipeUp()
sleep(1)
// Update description
let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch
if descField.exists {
descField.tap()
sleep(1)
// Clear existing text
descField.doubleTap()
sleep(1)
if app.buttons["Select All"].exists {
app.buttons["Select All"].tap()
sleep(1)
}
descField.typeText(newDescription)
}
// Update category (if picker exists)
let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch
if categoryPicker.exists {
categoryPicker.tap()
sleep(1)
// Select a different category
let electricalOption = app.buttons["Electrical"]
if electricalOption.exists {
electricalOption.tap()
sleep(1)
}
}
// Scroll to more fields
app.swipeUp()
sleep(1)
// Update priority (if picker exists)
let priorityPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Priority'")).firstMatch
if priorityPicker.exists {
priorityPicker.tap()
sleep(1)
// Select high priority
let highOption = app.buttons["High"]
if highOption.exists {
highOption.tap()
sleep(1)
}
}
// Update status (if picker exists)
let statusPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Status'")).firstMatch
if statusPicker.exists {
statusPicker.tap()
sleep(1)
// Select in progress status
let inProgressOption = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'In Progress' OR label CONTAINS[c] 'InProgress'")).firstMatch
if inProgressOption.exists {
inProgressOption.tap()
sleep(1)
}
}
// Scroll to save button
app.swipeUp()
sleep(1)
// Save
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
XCTAssertTrue(saveButton.exists, "Save button should exist")
saveButton.tap()
sleep(4)
// Track new title
createdTaskTitles.append(newTitle)
// Verify updated task appears in list with new title
navigateToTasksTab()
sleep(2)
let updatedTask = findTask(title: newTitle)
XCTAssertTrue(updatedTask.exists, "Task should show updated title in list")
// Tap on task to verify details were updated
updatedTask.tap()
sleep(2)
// Verify updated priority (High) appears
let highPriorityBadge = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'High'")).firstMatch
XCTAssertTrue(highPriorityBadge.exists || true, "Updated priority should be visible (if priority is shown in detail)")
}
// MARK: - 4. Navigation/View Tests
func test11_navigateFromTasksToOtherTabs() {
// From Tasks tab
navigateToTasksTab()
// Navigate to Residences
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
residencesTab.tap()
sleep(1)
XCTAssertTrue(residencesTab.isSelected, "Should be on Residences tab")
// Navigate back to Tasks
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
tasksTab.tap()
sleep(1)
XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab")
// Navigate to Contractors
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist")
contractorsTab.tap()
sleep(1)
XCTAssertTrue(contractorsTab.isSelected, "Should be on Contractors tab")
// Back to Tasks
tasksTab.tap()
sleep(1)
XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab again")
}
func test12_refreshTasksList() {
navigateToTasksTab()
sleep(2)
// Pull to refresh (if implemented) or use refresh button
let refreshButton = app.navigationBars.buttons.containing(NSPredicate(format: "label CONTAINS 'arrow.clockwise' OR label CONTAINS 'refresh'")).firstMatch
if refreshButton.exists {
refreshButton.tap()
sleep(3)
}
// Verify we're still on tasks tab
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
XCTAssertTrue(tasksTab.isSelected, "Should still be on Tasks tab after refresh")
}
// MARK: - 5. Persistence Tests
func test13_taskPersistsAfterBackgroundingApp() {
let timestamp = Int(Date().timeIntervalSince1970)
let taskTitle = "Persistence Test \(timestamp)"
// Create task
guard createTask(title: taskTitle) else {
XCTFail("Failed to create task")
return
}
navigateToTasksTab()
sleep(2)
// Verify task exists
var task = findTask(title: taskTitle)
XCTAssertTrue(task.exists, "Task should exist before backgrounding")
// Background and reactivate app
XCUIDevice.shared.press(.home)
sleep(2)
app.activate()
sleep(3)
// Navigate back to tasks
navigateToTasksTab()
sleep(2)
// Verify task still exists
task = findTask(title: taskTitle)
XCTAssertTrue(task.exists, "Task should persist after backgrounding app")
}
// MARK: - 6. Performance Tests
func test14_taskListPerformance() {
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
navigateToTasksTab()
sleep(2)
}
}
func test15_taskCreationPerformance() {
let timestamp = Int(Date().timeIntervalSince1970)
measure(metrics: [XCTClockMetric()]) {
let taskTitle = "Performance Test \(timestamp)_\(UUID().uuidString.prefix(8))"
_ = createTask(title: taskTitle)
}
}
}

View File

@@ -0,0 +1,717 @@
import XCTest
/// Comprehensive contractor testing suite covering all scenarios, edge cases, and variations
/// This test suite is designed to be bulletproof and catch regressions early
final class Suite7_ContractorTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
// Test data tracking
var createdContractorNames: [String] = []
override func setUpWithError() throws {
try super.setUpWithError()
// Ensure user is logged in
UITestHelpers.ensureLoggedIn(app: app)
// Navigate to Contractors tab
navigateToContractorsTab()
}
override func tearDownWithError() throws {
createdContractorNames.removeAll()
try super.tearDownWithError()
}
// MARK: - Helper Methods
private func navigateToContractorsTab() {
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
if contractorsTab.waitForExistence(timeout: 5) {
if !contractorsTab.isSelected {
contractorsTab.tap()
sleep(3)
}
}
}
private func openContractorForm() -> Bool {
let addButton = findAddContractorButton()
guard addButton.exists && addButton.isEnabled else { return false }
addButton.tap()
sleep(3)
// Verify form opened
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
return nameField.waitForExistence(timeout: 5)
}
private func findAddContractorButton() -> XCUIElement {
sleep(2)
// Look for add button by various methods
let navBarButtons = app.navigationBars.buttons
for i in 0..<navBarButtons.count {
let button = navBarButtons.element(boundBy: i)
if button.label == "plus" || button.label.contains("Add") {
if button.isEnabled {
return button
}
}
}
// Fallback: look for any button with plus icon
return app.buttons.containing(NSPredicate(format: "label CONTAINS 'plus'")).firstMatch
}
private func fillTextField(placeholder: String, text: String) {
let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch
if field.exists {
field.tap()
field.typeText(text)
}
}
private func selectSpecialty(specialty: String) {
let specialtyPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Specialty'")).firstMatch
if specialtyPicker.exists {
specialtyPicker.tap()
sleep(1)
// Try to find and tap the specialty option
let specialtyButton = app.buttons[specialty]
if specialtyButton.exists {
specialtyButton.tap()
sleep(1)
} else {
// Try cells if it's a navigation style picker
let cells = app.cells
for i in 0..<cells.count {
let cell = cells.element(boundBy: i)
if cell.staticTexts[specialty].exists {
cell.tap()
sleep(1)
break
}
}
}
}
}
private func createContractor(
name: String,
phone: String = "555-123-4567",
email: String? = nil,
company: String? = nil,
specialty: String? = nil,
scrollBeforeSave: Bool = true
) -> Bool {
guard openContractorForm() else { return false }
// Fill name
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
nameField.tap()
nameField.typeText(name)
// Fill phone (required field)
fillTextField(placeholder: "Phone", text: phone)
// Fill optional fields
if let email = email {
fillTextField(placeholder: "Email", text: email)
}
if let company = company {
fillTextField(placeholder: "Company", text: company)
}
// Select specialty if provided
if let specialty = specialty {
selectSpecialty(specialty: specialty)
}
// Scroll to save button if needed
if scrollBeforeSave {
app.swipeUp()
sleep(1)
}
// Add button (for creating new contractors)
let addButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch
guard addButton.exists else { return false }
addButton.tap()
sleep(4) // Wait for API call
// Track created contractor
createdContractorNames.append(name)
return true
}
private func findContractor(name: String, scrollIfNeeded: Bool = true) -> XCUIElement {
let element = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
// If element is visible, return it immediately
if element.exists && element.isHittable {
return element
}
// If scrolling is not needed, return the element as-is
guard scrollIfNeeded else {
return element
}
// Get the scroll view
let scrollView = app.scrollViews.firstMatch
guard scrollView.exists else {
return element
}
// First, scroll to the top of the list
scrollView.swipeDown(velocity: .fast)
usleep(30_000) // 0.03 second delay
// Now scroll down from top, checking after each swipe
var lastVisibleRow = ""
for _ in 0..<Int.max {
// Check if element is now visible
if element.exists && element.isHittable {
return element
}
// Get the last visible row before swiping
let visibleTexts = app.staticTexts.allElementsBoundByIndex.filter { $0.isHittable }
let currentLastRow = visibleTexts.last?.label ?? ""
// If last row hasn't changed, we've reached the end
if !lastVisibleRow.isEmpty && currentLastRow == lastVisibleRow {
break
}
lastVisibleRow = currentLastRow
// Scroll down one swipe
scrollView.swipeUp(velocity: .slow)
usleep(50_000) // 0.05 second delay
}
// Return element (test assertions will handle if not found)
return element
}
// MARK: - 1. Validation & Error Handling Tests
func test01_cannotCreateContractorWithEmptyName() {
guard openContractorForm() else {
XCTFail("Failed to open contractor form")
return
}
// Leave name empty, fill only phone
fillTextField(placeholder: "Phone", text: "555-123-4567")
// Scroll to Add button if needed
app.swipeUp()
sleep(1)
// When creating, button should say "Add"
let addButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch
XCTAssertTrue(addButton.exists, "Add button should exist when creating contractor")
XCTAssertFalse(addButton.isEnabled, "Add button should be disabled when name is empty")
}
func test02_cancelContractorCreation() {
guard openContractorForm() else {
XCTFail("Failed to open contractor form")
return
}
// Fill some data
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
nameField.tap()
nameField.typeText("This will be canceled")
// Tap cancel
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
XCTAssertTrue(cancelButton.exists, "Cancel button should exist")
cancelButton.tap()
sleep(2)
// Should be back on contractors list
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
XCTAssertTrue(contractorsTab.exists, "Should be back on contractors list")
// Contractor should not exist
let contractor = findContractor(name: "This will be canceled")
XCTAssertFalse(contractor.exists, "Canceled contractor should not exist")
}
// MARK: - 2. Basic Contractor Creation Tests
func test03_createContractorWithMinimalData() {
let timestamp = Int(Date().timeIntervalSince1970)
let contractorName = "John Doe \(timestamp)"
let success = createContractor(name: contractorName)
XCTAssertTrue(success, "Should successfully create contractor with minimal data")
let contractorInList = findContractor(name: contractorName)
XCTAssertTrue(contractorInList.waitForExistence(timeout: 10), "Contractor should appear in list")
}
func test04_createContractorWithAllFields() {
let timestamp = Int(Date().timeIntervalSince1970)
let contractorName = "Jane Smith \(timestamp)"
let success = createContractor(
name: contractorName,
phone: "555-987-6543",
email: "jane.smith@example.com",
company: "Smith Plumbing Inc",
specialty: "Plumbing"
)
XCTAssertTrue(success, "Should successfully create contractor with all fields")
let contractorInList = findContractor(name: contractorName)
XCTAssertTrue(contractorInList.waitForExistence(timeout: 10), "Complete contractor should appear in list")
}
func test05_createContractorWithDifferentSpecialties() {
let timestamp = Int(Date().timeIntervalSince1970)
let specialties = ["Plumbing", "Electrical", "HVAC"]
for (index, specialty) in specialties.enumerated() {
let contractorName = "\(specialty) Expert \(timestamp)_\(index)"
let success = createContractor(name: contractorName, specialty: specialty)
XCTAssertTrue(success, "Should create \(specialty) contractor")
navigateToContractorsTab()
sleep(2)
}
// Verify all contractors exist
for (index, specialty) in specialties.enumerated() {
let contractorName = "\(specialty) Expert \(timestamp)_\(index)"
let contractor = findContractor(name: contractorName)
XCTAssertTrue(contractor.exists, "\(specialty) contractor should exist in list")
}
}
func test06_createMultipleContractorsInSequence() {
let timestamp = Int(Date().timeIntervalSince1970)
for i in 1...3 {
let contractorName = "Sequential Contractor \(i) - \(timestamp)"
let success = createContractor(name: contractorName)
XCTAssertTrue(success, "Should create contractor \(i)")
navigateToContractorsTab()
sleep(2)
}
// Verify all contractors exist
for i in 1...3 {
let contractorName = "Sequential Contractor \(i) - \(timestamp)"
let contractor = findContractor(name: contractorName)
XCTAssertTrue(contractor.exists, "Contractor \(i) should exist in list")
}
}
// MARK: - 3. Edge Case Tests - Phone Numbers
func test07_createContractorWithDifferentPhoneFormats() {
let timestamp = Int(Date().timeIntervalSince1970)
let phoneFormats = [
("555-123-4567", "Dashed"),
("(555) 123-4567", "Parentheses"),
("5551234567", "NoFormat"),
("555.123.4567", "Dotted")
]
for (index, (phone, format)) in phoneFormats.enumerated() {
let contractorName = "\(format) Phone \(timestamp)_\(index)"
let success = createContractor(name: contractorName, phone: phone)
XCTAssertTrue(success, "Should create contractor with \(format) phone format")
navigateToContractorsTab()
sleep(2)
}
// Verify all contractors exist
for (index, (_, format)) in phoneFormats.enumerated() {
let contractorName = "\(format) Phone \(timestamp)_\(index)"
let contractor = findContractor(name: contractorName)
XCTAssertTrue(contractor.exists, "Contractor with \(format) phone should exist")
}
}
// MARK: - 4. Edge Case Tests - Emails
func test08_createContractorWithValidEmails() {
let timestamp = Int(Date().timeIntervalSince1970)
let emails = [
"simple@example.com",
"firstname.lastname@example.com",
"email+tag@example.co.uk",
"email_with_underscore@example.com"
]
for (index, email) in emails.enumerated() {
let contractorName = "Email Test \(index) - \(timestamp)"
let success = createContractor(name: contractorName, email: email)
XCTAssertTrue(success, "Should create contractor with email: \(email)")
navigateToContractorsTab()
sleep(2)
}
}
// MARK: - 5. Edge Case Tests - Names
func test09_createContractorWithVeryLongName() {
let timestamp = Int(Date().timeIntervalSince1970)
let longName = "John Christopher Alexander Montgomery Wellington III Esquire \(timestamp)"
let success = createContractor(name: longName)
XCTAssertTrue(success, "Should handle very long names")
// Verify it appears (may be truncated in display)
let contractor = findContractor(name: "John Christopher")
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Long name contractor should exist")
}
func test10_createContractorWithSpecialCharactersInName() {
let timestamp = Int(Date().timeIntervalSince1970)
let specialName = "O'Brien-Smith Jr. \(timestamp)"
let success = createContractor(name: specialName)
XCTAssertTrue(success, "Should handle special characters in names")
let contractor = findContractor(name: "O'Brien")
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with special chars should exist")
}
func test11_createContractorWithInternationalCharacters() {
let timestamp = Int(Date().timeIntervalSince1970)
let internationalName = "José García \(timestamp)"
let success = createContractor(name: internationalName)
XCTAssertTrue(success, "Should handle international characters")
let contractor = findContractor(name: "José")
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with international chars should exist")
}
func test12_createContractorWithEmojisInName() {
let timestamp = Int(Date().timeIntervalSince1970)
let emojiName = "Bob 🔧 Builder \(timestamp)"
let success = createContractor(name: emojiName)
XCTAssertTrue(success, "Should handle emojis in names")
let contractor = findContractor(name: "Bob")
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with emojis should exist")
}
// MARK: - 6. Contractor Editing Tests
func test13_editContractorName() {
let timestamp = Int(Date().timeIntervalSince1970)
let originalName = "Original Contractor \(timestamp)"
let newName = "Edited Contractor \(timestamp)"
// Create contractor
guard createContractor(name: originalName) else {
XCTFail("Failed to create contractor")
return
}
navigateToContractorsTab()
sleep(2)
// Find and tap contractor
let contractor = findContractor(name: originalName)
XCTAssertTrue(contractor.waitForExistence(timeout: 5), "Contractor should exist")
contractor.tap()
sleep(2)
// Tap edit button (may be in menu)
app/*@START_MENU_TOKEN@*/.images["ellipsis.circle"]/*[[".buttons[\"More\"].images",".buttons",".images[\"More\"]",".images[\"ellipsis.circle\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
app/*@START_MENU_TOKEN@*/.buttons["pencil"]/*[[".buttons.containing(.image, identifier: \"pencil\")",".cells",".buttons[\"Edit\"]",".buttons[\"pencil\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
// Edit name
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
if nameField.exists {
nameField.tap()
sleep(1)
nameField.tap()
sleep(1)
app.menuItems["Select All"].tap()
sleep(1)
nameField.typeText(newName)
// Save (when editing, button should say "Save")
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
if saveButton.exists {
saveButton.tap()
sleep(3)
// Track new name
createdContractorNames.append(newName)
}
}
}
func test14_updateAllContractorFields() {
let timestamp = Int(Date().timeIntervalSince1970)
let originalName = "Update All Fields \(timestamp)"
let newName = "All Fields Updated \(timestamp)"
let newPhone = "999-888-7777"
let newEmail = "updated@contractor.com"
let newCompany = "Updated Company LLC"
// Create contractor with initial values
guard createContractor(
name: originalName,
phone: "555-123-4567",
email: "original@contractor.com",
company: "Original Company"
) else {
XCTFail("Failed to create contractor")
return
}
navigateToContractorsTab()
sleep(2)
// Find and tap contractor
let contractor = findContractor(name: originalName)
XCTAssertTrue(contractor.waitForExistence(timeout: 5), "Contractor should exist")
contractor.tap()
sleep(2)
// Tap edit button (may be in menu)
app/*@START_MENU_TOKEN@*/.images["ellipsis.circle"]/*[[".buttons[\"More\"].images",".buttons",".images[\"More\"]",".images[\"ellipsis.circle\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
app/*@START_MENU_TOKEN@*/.buttons["pencil"]/*[[".buttons.containing(.image, identifier: \"pencil\")",".cells",".buttons[\"Edit\"]",".buttons[\"pencil\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
// Update name
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
XCTAssertTrue(nameField.exists, "Name field should exist")
nameField.tap()
sleep(1)
nameField.tap()
sleep(1)
app.menuItems["Select All"].tap()
sleep(1)
nameField.typeText(newName)
// Update phone
let phoneField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Phone'")).firstMatch
if phoneField.exists {
phoneField.tap()
sleep(1)
phoneField.tap()
sleep(1)
app.menuItems["Select All"].tap()
phoneField.typeText(newPhone)
}
// Update email
let emailField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Email'")).firstMatch
if emailField.exists {
emailField.tap()
sleep(1)
emailField.tap()
sleep(1)
app.menuItems["Select All"].tap()
emailField.typeText(newEmail)
}
// Update company
let companyField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Company'")).firstMatch
if companyField.exists {
companyField.tap()
sleep(1)
companyField.tap()
sleep(1)
app.menuItems["Select All"].tap()
companyField.typeText(newCompany)
}
// Update specialty (if picker exists)
let specialtyPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Specialty'")).firstMatch
if specialtyPicker.exists {
specialtyPicker.tap()
sleep(1)
// Select HVAC
let hvacOption = app.buttons["HVAC"]
if hvacOption.exists {
hvacOption.tap()
sleep(1)
}
}
// Save (when editing, button should say "Save")
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
XCTAssertTrue(saveButton.exists, "Save button should exist when editing contractor")
saveButton.tap()
sleep(4)
// Track new name
createdContractorNames.append(newName)
// Verify updated contractor appears in list with new name
navigateToContractorsTab()
sleep(2)
let updatedContractor = findContractor(name: newName)
XCTAssertTrue(updatedContractor.exists, "Contractor should show updated name in list")
// Tap on contractor to verify details were updated
updatedContractor.tap()
sleep(2)
// Verify updated phone appears in detail view
let phoneText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newPhone)' OR label CONTAINS '999-888-7777' OR label CONTAINS '9998887777'")).firstMatch
XCTAssertTrue(phoneText.exists, "Updated phone should be visible in detail view")
// Verify updated email appears in detail view
let emailText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newEmail)'")).firstMatch
XCTAssertTrue(emailText.exists, "Updated email should be visible in detail view")
// Verify updated company appears in detail view
let companyText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newCompany)'")).firstMatch
XCTAssertTrue(companyText.exists, "Updated company should be visible in detail view")
// Verify updated specialty (HVAC) appears
let hvacBadge = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'HVAC'")).firstMatch
XCTAssertTrue(hvacBadge.exists || true, "Updated specialty should be visible (if shown in detail)")
}
// MARK: - 7. Navigation & List Tests
func test15_navigateFromContractorsToOtherTabs() {
// From Contractors tab
navigateToContractorsTab()
// Navigate to Residences
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
residencesTab.tap()
sleep(1)
XCTAssertTrue(residencesTab.isSelected, "Should be on Residences tab")
// Navigate back to Contractors
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
contractorsTab.tap()
sleep(1)
XCTAssertTrue(contractorsTab.isSelected, "Should be back on Contractors tab")
// Navigate to Tasks
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist")
tasksTab.tap()
sleep(1)
XCTAssertTrue(tasksTab.isSelected, "Should be on Tasks tab")
// Back to Contractors
contractorsTab.tap()
sleep(1)
XCTAssertTrue(contractorsTab.isSelected, "Should be back on Contractors tab again")
}
func test16_refreshContractorsList() {
navigateToContractorsTab()
sleep(2)
// Pull to refresh (if implemented) or use refresh button
let refreshButton = app.navigationBars.buttons.containing(NSPredicate(format: "label CONTAINS 'arrow.clockwise' OR label CONTAINS 'refresh'")).firstMatch
if refreshButton.exists {
refreshButton.tap()
sleep(3)
}
// Verify we're still on contractors tab
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
XCTAssertTrue(contractorsTab.isSelected, "Should still be on Contractors tab after refresh")
}
func test17_viewContractorDetails() {
let timestamp = Int(Date().timeIntervalSince1970)
let contractorName = "Detail View Test \(timestamp)"
// Create contractor
guard createContractor(name: contractorName, email: "test@example.com", company: "Test Company") else {
XCTFail("Failed to create contractor")
return
}
navigateToContractorsTab()
sleep(2)
// Tap on contractor
let contractor = findContractor(name: contractorName)
XCTAssertTrue(contractor.exists, "Contractor should exist")
contractor.tap()
sleep(3)
// Verify detail view appears with contact info
let phoneLabel = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Phone' OR label CONTAINS '555'")).firstMatch
let emailLabel = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Email' OR label CONTAINS 'test@example.com'")).firstMatch
XCTAssertTrue(phoneLabel.exists || emailLabel.exists, "Detail view should show contact information")
}
// MARK: - 8. Data Persistence Tests
func test18_contractorPersistsAfterBackgroundingApp() {
let timestamp = Int(Date().timeIntervalSince1970)
let contractorName = "Persistence Test \(timestamp)"
// Create contractor
guard createContractor(name: contractorName) else {
XCTFail("Failed to create contractor")
return
}
navigateToContractorsTab()
sleep(2)
// Verify contractor exists
var contractor = findContractor(name: contractorName)
XCTAssertTrue(contractor.exists, "Contractor should exist before backgrounding")
// Background and reactivate app
XCUIDevice.shared.press(.home)
sleep(2)
app.activate()
sleep(3)
// Navigate back to contractors
navigateToContractorsTab()
sleep(2)
// Verify contractor still exists
contractor = findContractor(name: contractorName)
XCTAssertTrue(contractor.exists, "Contractor should persist after backgrounding app")
}
// MARK: - 9. Performance Tests
func test19_contractorListPerformance() {
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
navigateToContractorsTab()
sleep(2)
}
}
func test20_contractorCreationPerformance() {
let timestamp = Int(Date().timeIntervalSince1970)
measure(metrics: [XCTClockMetric()]) {
let contractorName = "Performance Test \(timestamp)_\(UUID().uuidString.prefix(8))"
_ = createContractor(name: contractorName)
}
}
}

View File

@@ -0,0 +1,944 @@
import XCTest
/// Comprehensive documents and warranties testing suite covering all scenarios, edge cases, and variations
/// Tests both document types (permits, receipts, etc.) and warranties with filtering, searching, and CRUD operations
final class Suite8_DocumentWarrantyTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
// Test data tracking
var createdDocumentTitles: [String] = []
var currentResidenceId: Int32?
override func setUpWithError() throws {
try super.setUpWithError()
// Ensure user is logged in
UITestHelpers.ensureLoggedIn(app: app)
// Navigate to a residence first (documents are residence-specific)
navigateToFirstResidence()
}
override func tearDownWithError() throws {
createdDocumentTitles.removeAll()
currentResidenceId = nil
try super.tearDownWithError()
}
// MARK: - Helper Methods
private func navigateToFirstResidence() {
// Tap Residences tab
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
if residencesTab.waitForExistence(timeout: 5) {
residencesTab.tap()
sleep(3)
}
// Tap first residence card
let firstResidence = app.collectionViews.cells.firstMatch
if firstResidence.waitForExistence(timeout: 5) {
firstResidence.tap()
sleep(2)
}
}
private func navigateToDocumentsTab() {
// Look for Documents tab or navigation link
let documentsButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents' OR label CONTAINS[c] 'Warranties'")).firstMatch
if documentsButton.waitForExistence(timeout: 5) {
documentsButton.tap()
sleep(3)
}
}
private func openDocumentForm() -> Bool {
let addButton = findAddButton()
guard addButton.exists && addButton.isEnabled else { return false }
addButton.tap()
sleep(3)
// Verify form opened
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
return titleField.waitForExistence(timeout: 5)
}
private func findAddButton() -> XCUIElement {
sleep(2)
// Look for add button by various methods
let navBarButtons = app.navigationBars.buttons
for i in 0..<navBarButtons.count {
let button = navBarButtons.element(boundBy: i)
if button.label == "plus" || button.label.contains("Add") {
if button.isEnabled {
return button
}
}
}
// Fallback: look for any button with plus icon
return app.buttons.containing(NSPredicate(format: "label CONTAINS 'plus'")).firstMatch
}
private func fillTextField(placeholder: String, text: String) {
let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch
if field.exists {
field.tap()
field.typeText(text)
}
}
private func fillTextEditor(text: String) {
let textEditor = app.textViews.firstMatch
if textEditor.exists {
textEditor.tap()
textEditor.typeText(text)
}
}
private func selectProperty() {
// Open the picker
app.buttons["Select Property, Select Property"].tap()
// Try cells first (common for Picker list)
let secondCell = app.cells.element(boundBy: 1)
if secondCell.waitForExistence(timeout: 5) {
secondCell.tap()
} else {
// Fallback: second static text after the title
let allTexts = app.staticTexts.allElementsBoundByIndex
// Expect something like: [ "Select Property" (title), "Select Property", "Test Home for Comprehensive Tasks", ... ]
// So the second item row label is usually at index 2
let secondItemText = allTexts[2]
secondItemText.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
}
private func selectDocumentType(type: String) {
let typePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Type'")).firstMatch
if typePicker.exists {
typePicker.tap()
sleep(1)
let typeButton = app.buttons[type]
if typeButton.exists {
typeButton.tap()
sleep(1)
} else {
// Try cells if it's a navigation style picker
let cells = app.cells
for i in 0..<cells.count {
let cell = cells.element(boundBy: i)
if cell.staticTexts[type].exists {
cell.tap()
sleep(1)
break
}
}
}
}
}
private func selectCategory(category: String) {
let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch
if categoryPicker.exists {
categoryPicker.tap()
sleep(1)
let categoryButton = app.buttons[category]
if categoryButton.exists {
categoryButton.tap()
sleep(1)
} else {
let cells = app.cells
for i in 0..<cells.count {
let cell = cells.element(boundBy: i)
if cell.staticTexts[category].exists {
cell.tap()
sleep(1)
break
}
}
}
}
}
private func selectDate(dateType: String, daysFromNow: Int) {
let datePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(dateType)'")).firstMatch
if datePicker.exists {
datePicker.tap()
sleep(1)
// Look for date picker and set date
let datePickerWheel = app.datePickers.firstMatch
if datePickerWheel.exists {
let calendar = Calendar.current
let targetDate = calendar.date(byAdding: .day, value: daysFromNow, to: Date())!
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy"
let dateString = formatter.string(from: targetDate)
// Try to type the date or interact with picker
sleep(1)
}
// Dismiss picker
app.buttons["Done"].tap()
sleep(1)
}
}
private func submitForm() -> Bool {
let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")).firstMatch
guard submitButton.exists && submitButton.isEnabled else { return false }
submitButton.tap()
sleep(3)
return true
}
private func cancelForm() {
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
if cancelButton.exists {
cancelButton.tap()
sleep(2)
}
}
private func switchToWarrantiesTab() {
app/*@START_MENU_TOKEN@*/.buttons["checkmark.shield"]/*[[".segmentedControls",".buttons[\"Warranties\"]",".buttons[\"checkmark.shield\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
}
private func switchToDocumentsTab() {
app/*@START_MENU_TOKEN@*/.buttons["doc.text"]/*[[".segmentedControls",".buttons[\"Documents\"]",".buttons[\"doc.text\"]"],[[[-1,2],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
}
private func searchFor(text: String) {
let searchField = app.searchFields.firstMatch
if searchField.exists {
searchField.tap()
searchField.typeText(text)
sleep(2)
}
}
private func clearSearch() {
let searchField = app.searchFields.firstMatch
if searchField.exists {
let clearButton = searchField.buttons["Clear text"]
if clearButton.exists {
clearButton.tap()
sleep(1)
}
}
}
private func applyFilter(filterName: String) {
// Open filter menu
let filterButton = app.buttons.containing(NSPredicate(format: "label CONTAINS 'line.3.horizontal.decrease'")).firstMatch
if filterButton.exists {
filterButton.tap()
sleep(1)
// Select filter option
let filterOption = app.buttons[filterName]
if filterOption.exists {
filterOption.tap()
sleep(2)
}
}
}
private func toggleActiveFilter() {
let activeFilterButton = app.buttons.containing(NSPredicate(format: "label CONTAINS 'checkmark.circle'")).firstMatch
if activeFilterButton.exists {
activeFilterButton.tap()
sleep(2)
}
}
// MARK: - Test Cases
// MARK: Navigation Tests
func test01_NavigateToDocumentsScreen() {
navigateToDocumentsTab()
// Verify we're on documents screen
let navigationTitle = app.navigationBars["Documents & Warranties"]
XCTAssertTrue(navigationTitle.waitForExistence(timeout: 5), "Should navigate to Documents & Warranties screen")
// Verify tabs are visible
let warrantiesTab = app.buttons["Warranties"]
let documentsTab = app.buttons["Documents"]
XCTAssertTrue(warrantiesTab.exists || documentsTab.exists, "Should see tab switcher")
}
func test02_SwitchBetweenWarrantiesAndDocuments() {
navigateToDocumentsTab()
// Start on warranties tab
switchToWarrantiesTab()
sleep(1)
// Switch to documents tab
switchToDocumentsTab()
sleep(1)
// Switch back to warranties
switchToWarrantiesTab()
sleep(1)
// Should not crash and tabs should still exist
let warrantiesTab = app.buttons["Warranties"]
XCTAssertTrue(warrantiesTab.exists, "Tabs should remain functional after switching")
}
// MARK: Document Creation Tests
func test03_CreateDocumentWithAllFields() {
navigateToDocumentsTab()
switchToDocumentsTab()
XCTAssertTrue(openDocumentForm(), "Should open document form")
let testTitle = "Test Permit \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(testTitle)
// Fill all fields
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: testTitle)
selectDocumentType(type: "Insurance")
fillTextEditor(text: "Test permit description with detailed information")
fillTextField(placeholder: "Tags", text: "construction,permit")
fillTextField(placeholder: "Item Name", text: "Kitchen Renovation")
fillTextField(placeholder: "Location", text: "Main Kitchen")
XCTAssertTrue(submitForm(), "Should submit form successfully")
// Verify document appears in list
sleep(2)
let documentCard = app.staticTexts[testTitle]
XCTAssertTrue(documentCard.exists, "Created document should appear in list")
}
func test04_CreateDocumentWithMinimalFields() {
navigateToDocumentsTab()
switchToDocumentsTab()
XCTAssertTrue(openDocumentForm(), "Should open document form")
let testTitle = "Min Doc \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(testTitle)
// Fill only required fields
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: testTitle)
selectDocumentType(type: "Insurance")
XCTAssertTrue(submitForm(), "Should submit form with minimal fields")
// Verify document appears
sleep(2)
let documentCard = app.staticTexts[testTitle]
XCTAssertTrue(documentCard.exists, "Document with minimal fields should appear")
}
func test05_CreateDocumentWithEmptyTitle_ShouldFail() {
navigateToDocumentsTab()
switchToDocumentsTab()
XCTAssertTrue(openDocumentForm(), "Should open document form")
// Try to submit without title
selectProperty() // REQUIRED - Select property first
selectDocumentType(type: "Insurance")
let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add'")).firstMatch
// Submit button should be disabled or show error
if submitButton.exists && submitButton.isEnabled {
submitButton.tap()
sleep(2)
// Should show error message
let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'required' OR label CONTAINS[c] 'title'")).firstMatch
XCTAssertTrue(errorMessage.exists, "Should show validation error for missing title")
}
cancelForm()
}
// MARK: Warranty Creation Tests
func test06_CreateWarrantyWithAllFields() {
navigateToDocumentsTab()
switchToWarrantiesTab()
XCTAssertTrue(openDocumentForm(), "Should open warranty form")
let testTitle = "Test Warranty \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(testTitle)
// Fill all warranty fields (including required fields)
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: testTitle)
selectCategory(category: "Appliances")
fillTextField(placeholder: "Item Name", text: "Dishwasher") // REQUIRED
fillTextField(placeholder: "Provider", text: "Bosch") // REQUIRED
fillTextField(placeholder: "Model", text: "SHPM65Z55N")
fillTextField(placeholder: "Serial", text: "SN123456789")
fillTextField(placeholder: "Provider Contact", text: "1-800-BOSCH-00")
fillTextEditor(text: "Full warranty coverage for 2 years")
// Select dates
selectDate(dateType: "Start Date", daysFromNow: -30)
selectDate(dateType: "End Date", daysFromNow: 700) // ~2 years
XCTAssertTrue(submitForm(), "Should submit warranty successfully")
// Verify warranty appears
sleep(2)
let warrantyCard = app.staticTexts[testTitle]
XCTAssertTrue(warrantyCard.exists, "Created warranty should appear in list")
}
func test07_CreateWarrantyWithFutureDates() {
navigateToDocumentsTab()
switchToWarrantiesTab()
XCTAssertTrue(openDocumentForm(), "Should open warranty form")
let testTitle = "Future Warranty \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(testTitle)
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: testTitle)
selectCategory(category: "HVAC")
fillTextField(placeholder: "Item Name", text: "Air Conditioner") // REQUIRED
fillTextField(placeholder: "Provider", text: "Carrier HVAC") // REQUIRED
// Set start date in future
selectDate(dateType: "Start Date", daysFromNow: 30)
selectDate(dateType: "End Date", daysFromNow: 400)
XCTAssertTrue(submitForm(), "Should create warranty with future dates")
sleep(2)
let warrantyCard = app.staticTexts[testTitle]
XCTAssertTrue(warrantyCard.exists, "Warranty with future dates should be created")
}
func test08_CreateExpiredWarranty() {
navigateToDocumentsTab()
switchToWarrantiesTab()
XCTAssertTrue(openDocumentForm(), "Should open warranty form")
let testTitle = "Expired Warranty \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(testTitle)
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: testTitle)
selectCategory(category: "Plumbing")
fillTextField(placeholder: "Item Name", text: "Water Heater") // REQUIRED
fillTextField(placeholder: "Provider", text: "AO Smith") // REQUIRED
// Set dates in the past
selectDate(dateType: "Start Date", daysFromNow: -400)
selectDate(dateType: "End Date", daysFromNow: -30)
XCTAssertTrue(submitForm(), "Should create expired warranty")
sleep(2)
// Expired warranty might not show with active filter on
// Toggle active filter off to see it
toggleActiveFilter()
sleep(1)
let warrantyCard = app.staticTexts[testTitle]
XCTAssertTrue(warrantyCard.exists, "Expired warranty should be created and visible when filter is off")
}
// MARK: Search and Filter Tests
func test09_SearchDocumentsByTitle() {
navigateToDocumentsTab()
switchToDocumentsTab()
// Create a test document first
XCTAssertTrue(openDocumentForm(), "Should open form")
let searchableTitle = "Searchable Doc \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(searchableTitle)
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: searchableTitle)
selectDocumentType(type: "Insurance")
XCTAssertTrue(submitForm(), "Should create document")
sleep(2)
// Search for it
searchFor(text: String(searchableTitle.prefix(15)))
// Should find the document
let foundDocument = app.staticTexts[searchableTitle]
XCTAssertTrue(foundDocument.exists, "Should find document by search")
clearSearch()
}
func test10_FilterWarrantiesByCategory() {
navigateToDocumentsTab()
switchToWarrantiesTab()
// Apply category filter
applyFilter(filterName: "Appliances")
sleep(2)
// Should show filter chip or indication
let filterChip = app.staticTexts["Appliances"]
XCTAssertTrue(filterChip.exists || app.buttons["Appliances"].exists, "Should show active category filter")
// Clear filter
applyFilter(filterName: "All Categories")
}
func test11_FilterDocumentsByType() {
navigateToDocumentsTab()
switchToDocumentsTab()
// Apply type filter
applyFilter(filterName: "Permit")
sleep(2)
// Should show filter indication
let filterChip = app.staticTexts["Permit"]
XCTAssertTrue(filterChip.exists || app.buttons["Permit"].exists, "Should show active type filter")
// Clear filter
applyFilter(filterName: "All Types")
}
func test12_ToggleActiveWarrantiesFilter() {
navigateToDocumentsTab()
switchToWarrantiesTab()
// Toggle active filter off
toggleActiveFilter()
sleep(1)
// Toggle it back on
toggleActiveFilter()
sleep(1)
// Should not crash
let warrantiesTab = app.buttons["Warranties"]
XCTAssertTrue(warrantiesTab.exists, "Active filter toggle should work without crashing")
}
// MARK: Document Detail Tests
func test13_ViewDocumentDetail() {
navigateToDocumentsTab()
switchToDocumentsTab()
// Create a document
XCTAssertTrue(openDocumentForm(), "Should open form")
let testTitle = "Detail Test Doc \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(testTitle)
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: testTitle)
selectDocumentType(type: "Insurance")
fillTextEditor(text: "This is a test receipt with details")
XCTAssertTrue(submitForm(), "Should create document")
sleep(2)
// Tap on the document card
let documentCard = app.staticTexts[testTitle]
XCTAssertTrue(documentCard.exists, "Document should exist in list")
documentCard.tap()
sleep(2)
// Should show detail screen
let detailTitle = app.staticTexts[testTitle]
XCTAssertTrue(detailTitle.exists, "Should show document detail screen")
// Go back
let backButton = app.navigationBars.buttons.firstMatch
backButton.tap()
sleep(1)
}
func test14_ViewWarrantyDetailWithDates() {
navigateToDocumentsTab()
switchToWarrantiesTab()
// Create a warranty
XCTAssertTrue(openDocumentForm(), "Should open form")
let testTitle = "Warranty Detail Test \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(testTitle)
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: testTitle)
selectCategory(category: "Appliances")
fillTextField(placeholder: "Item Name", text: "Test Appliance") // REQUIRED
fillTextField(placeholder: "Provider", text: "Test Company") // REQUIRED
selectDate(dateType: "Start Date", daysFromNow: -30)
selectDate(dateType: "End Date", daysFromNow: 335)
XCTAssertTrue(submitForm(), "Should create warranty")
sleep(2)
// Tap on warranty
let warrantyCard = app.staticTexts[testTitle]
XCTAssertTrue(warrantyCard.exists, "Warranty should exist")
warrantyCard.tap()
sleep(2)
// Should show warranty details with dates
let detailScreen = app.staticTexts[testTitle]
XCTAssertTrue(detailScreen.exists, "Should show warranty detail")
// Look for date information
let dateLabels = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] '20' OR label CONTAINS[c] 'Start' OR label CONTAINS[c] 'End'"))
XCTAssertTrue(dateLabels.count > 0, "Should display date information")
// Go back
app.navigationBars.buttons.firstMatch.tap()
sleep(1)
}
// MARK: Edit Tests
func test15_EditDocumentTitle() {
navigateToDocumentsTab()
switchToDocumentsTab()
// Create document
XCTAssertTrue(openDocumentForm(), "Should open form")
let originalTitle = "Edit Test \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(originalTitle)
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: originalTitle)
selectDocumentType(type: "Insurance")
XCTAssertTrue(submitForm(), "Should create document")
sleep(2)
// Open detail
let documentCard = app.staticTexts[originalTitle]
XCTAssertTrue(documentCard.exists, "Document should exist")
documentCard.tap()
sleep(2)
// Tap edit button
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
if editButton.exists {
editButton.tap()
sleep(2)
// Change title
let titleField = app.textFields.containing(NSPredicate(format: "value == '\(originalTitle)'")).firstMatch
if titleField.exists {
titleField.tap()
titleField.clearText()
let newTitle = "Edited \(originalTitle)"
titleField.typeText(newTitle)
createdDocumentTitles.append(newTitle)
XCTAssertTrue(submitForm(), "Should save edited document")
sleep(2)
// Verify new title appears
let updatedTitle = app.staticTexts[newTitle]
XCTAssertTrue(updatedTitle.exists, "Updated title should appear")
}
}
// Go back to list
app.navigationBars.buttons.element(boundBy: 0).tap()
sleep(1)
}
func test16_EditWarrantyDates() {
navigateToDocumentsTab()
switchToWarrantiesTab()
// Create warranty
XCTAssertTrue(openDocumentForm(), "Should open form")
let testTitle = "Edit Dates Warranty \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(testTitle)
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: testTitle)
selectCategory(category: "Electronics")
fillTextField(placeholder: "Item Name", text: "TV") // REQUIRED
fillTextField(placeholder: "Provider", text: "Samsung") // REQUIRED
selectDate(dateType: "Start Date", daysFromNow: -60)
selectDate(dateType: "End Date", daysFromNow: 305)
XCTAssertTrue(submitForm(), "Should create warranty")
sleep(2)
// Open and edit
let warrantyCard = app.staticTexts[testTitle]
XCTAssertTrue(warrantyCard.exists, "Warranty should exist")
warrantyCard.tap()
sleep(2)
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
if editButton.exists {
editButton.tap()
sleep(2)
// Change end date to extend warranty
selectDate(dateType: "End Date", daysFromNow: 730) // 2 years
XCTAssertTrue(submitForm(), "Should save edited warranty dates")
sleep(2)
}
app.navigationBars.buttons.element(boundBy: 0).tap()
sleep(1)
}
// MARK: Delete Tests
func test17_DeleteDocument() {
navigateToDocumentsTab()
switchToDocumentsTab()
// Create document to delete
XCTAssertTrue(openDocumentForm(), "Should open form")
let deleteTitle = "To Delete \(UUID().uuidString.prefix(8))"
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: deleteTitle)
selectDocumentType(type: "Insurance")
XCTAssertTrue(submitForm(), "Should create document")
sleep(2)
// Open detail
let documentCard = app.staticTexts[deleteTitle]
XCTAssertTrue(documentCard.exists, "Document should exist")
documentCard.tap()
sleep(2)
// Find and tap delete button
let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'trash'")).firstMatch
if deleteButton.exists {
deleteButton.tap()
sleep(1)
// Confirm deletion
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")).firstMatch
if confirmButton.exists {
confirmButton.tap()
sleep(2)
}
// Should navigate back to list
sleep(2)
// Verify document no longer exists
let deletedCard = app.staticTexts[deleteTitle]
XCTAssertFalse(deletedCard.exists, "Deleted document should not appear in list")
}
}
func test18_DeleteWarranty() {
navigateToDocumentsTab()
switchToWarrantiesTab()
// Create warranty to delete
XCTAssertTrue(openDocumentForm(), "Should open form")
let deleteTitle = "Warranty to Delete \(UUID().uuidString.prefix(8))"
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: deleteTitle)
selectCategory(category: "Other")
fillTextField(placeholder: "Item Name", text: "Test Item") // REQUIRED
fillTextField(placeholder: "Provider", text: "Test Provider") // REQUIRED
XCTAssertTrue(submitForm(), "Should create warranty")
sleep(2)
// Open and delete
let warrantyCard = app.staticTexts[deleteTitle]
XCTAssertTrue(warrantyCard.exists, "Warranty should exist")
warrantyCard.tap()
sleep(2)
let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'trash'")).firstMatch
if deleteButton.exists {
deleteButton.tap()
sleep(1)
// Confirm
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete'")).firstMatch
if confirmButton.exists {
confirmButton.tap()
sleep(2)
}
// Verify deleted
sleep(2)
let deletedCard = app.staticTexts[deleteTitle]
XCTAssertFalse(deletedCard.exists, "Deleted warranty should not appear")
}
}
// MARK: Edge Cases and Error Handling
func test19_CancelDocumentCreation() {
navigateToDocumentsTab()
switchToDocumentsTab()
XCTAssertTrue(openDocumentForm(), "Should open form")
// Fill some fields
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: "Cancelled Document")
selectDocumentType(type: "Insurance")
// Cancel instead of save
cancelForm()
// Should not appear in list
sleep(2)
let cancelledDoc = app.staticTexts["Cancelled Document"]
XCTAssertFalse(cancelledDoc.exists, "Cancelled document should not be created")
}
func test20_HandleEmptyDocumentsList() {
navigateToDocumentsTab()
switchToDocumentsTab()
// Apply very specific filter to get empty list
searchFor(text: "NONEXISTENT_DOCUMENT_12345")
sleep(2)
// Should show empty state
let emptyMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No documents' OR label CONTAINS[c] 'No results' OR label CONTAINS[c] 'empty'")).firstMatch
// Either empty state exists or no items are shown
let hasNoItems = app.cells.count == 0
XCTAssertTrue(emptyMessage.exists || hasNoItems, "Should handle empty documents list gracefully")
clearSearch()
}
func test21_HandleEmptyWarrantiesList() {
navigateToDocumentsTab()
switchToWarrantiesTab()
// Search for non-existent warranty
searchFor(text: "NONEXISTENT_WARRANTY_99999")
sleep(2)
let emptyMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No warranties' OR label CONTAINS[c] 'No results' OR label CONTAINS[c] 'empty'")).firstMatch
let hasNoItems = app.cells.count == 0
XCTAssertTrue(emptyMessage.exists || hasNoItems, "Should handle empty warranties list gracefully")
clearSearch()
}
func test22_CreateDocumentWithLongTitle() {
navigateToDocumentsTab()
switchToDocumentsTab()
XCTAssertTrue(openDocumentForm(), "Should open form")
let longTitle = "This is a very long document title that exceeds normal length expectations to test how the UI handles lengthy text input " + UUID().uuidString
createdDocumentTitles.append(longTitle)
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: longTitle)
selectDocumentType(type: "Insurance")
XCTAssertTrue(submitForm(), "Should handle long title")
sleep(2)
// Just verify it was created (partial match)
let partialTitle = String(longTitle.prefix(30))
let documentExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(partialTitle)'")).firstMatch.exists
XCTAssertTrue(documentExists, "Document with long title should be created")
}
func test23_CreateWarrantyWithSpecialCharacters() {
navigateToDocumentsTab()
switchToWarrantiesTab()
XCTAssertTrue(openDocumentForm(), "Should open form")
let specialTitle = "Warranty w/ Special #Chars: @ & $ % \(UUID().uuidString.prefix(8))"
createdDocumentTitles.append(specialTitle)
selectProperty() // REQUIRED - Select property first
fillTextField(placeholder: "Title", text: specialTitle)
selectCategory(category: "Other")
fillTextField(placeholder: "Item Name", text: "Test @#$ Item") // REQUIRED
fillTextField(placeholder: "Provider", text: "Special & Co.") // REQUIRED
XCTAssertTrue(submitForm(), "Should handle special characters")
sleep(2)
let partialTitle = String(specialTitle.prefix(20))
let warrantyExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(partialTitle)'")).firstMatch.exists
XCTAssertTrue(warrantyExists, "Warranty with special characters should be created")
}
func test24_RapidTabSwitching() {
navigateToDocumentsTab()
// Rapidly switch between tabs
for _ in 0..<5 {
switchToWarrantiesTab()
usleep(500000) // 0.5 seconds
switchToDocumentsTab()
usleep(500000) // 0.5 seconds
}
// Should remain stable
let warrantiesTab = app.buttons["Warranties"]
let documentsTab = app.buttons["Documents"]
XCTAssertTrue(warrantiesTab.exists && documentsTab.exists, "Should handle rapid tab switching without crashing")
}
func test25_MultipleFiltersCombined() {
navigateToDocumentsTab()
switchToWarrantiesTab()
// Apply multiple filters
toggleActiveFilter() // Turn off active filter
sleep(1)
applyFilter(filterName: "Appliances")
sleep(1)
searchFor(text: "Test")
sleep(2)
// Should apply all filters without crashing
let searchField = app.searchFields.firstMatch
XCTAssertTrue(searchField.exists, "Should handle multiple filters simultaneously")
// Clean up
clearSearch()
sleep(1)
applyFilter(filterName: "All Categories")
sleep(1)
toggleActiveFilter() // Turn active filter back on
}
}
// MARK: - XCUIElement Extension for Clearing Text
extension XCUIElement {
func clearText() {
guard let stringValue = self.value as? String else {
return
}
self.tap()
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
self.typeText(deleteString)
}
}

View File

@@ -0,0 +1,525 @@
import XCTest
/// Comprehensive End-to-End Integration Tests
/// Mirrors the backend integration tests in honeyDueAPI-go/internal/integration/integration_test.go
///
/// This test suite covers:
/// 1. Full authentication flow (register, login, logout)
/// 2. Residence CRUD operations
/// 3. Task lifecycle (create, update, mark-in-progress, complete, archive, cancel)
/// 4. Residence sharing between users
/// 5. Cross-user access control
///
/// IMPORTANT: These tests create real data and require network connectivity.
/// Run with a test server or dev environment (not production).
final class Suite9_IntegrationE2ETests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
// Test user credentials - unique per test run
private let timestamp = Int(Date().timeIntervalSince1970)
private var userAUsername: String { "e2e_usera_\(timestamp)" }
private var userAEmail: String { "e2e_usera_\(timestamp)@test.com" }
private var userAPassword: String { "TestPass123!" }
private var userBUsername: String { "e2e_userb_\(timestamp)" }
private var userBEmail: String { "e2e_userb_\(timestamp)@test.com" }
private var userBPassword: String { "TestPass456!" }
/// Fixed verification code used by Go API when DEBUG=true
private let verificationCode = "123456"
override func setUpWithError() throws {
try super.setUpWithError()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
}
// MARK: - Helper Methods
private func ensureLoggedOut() {
UITestHelpers.ensureLoggedOut(app: app)
}
private func login(username: String, password: String) {
UITestHelpers.login(app: app, username: username, password: password)
}
/// Navigate to a specific tab
private func navigateToTab(_ tabName: String) {
let tab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(tabName)'")).firstMatch
if tab.waitForExistence(timeout: 5) && !tab.isSelected {
tab.tap()
sleep(2)
}
}
/// Dismiss keyboard by tapping outside (doesn't submit forms)
private func dismissKeyboard() {
let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1))
coordinate.tap()
Thread.sleep(forTimeInterval: 0.5)
}
/// Dismiss strong password suggestion if shown
private func dismissStrongPasswordSuggestion() {
let chooseOwnPassword = app.buttons["Choose My Own Password"]
if chooseOwnPassword.waitForExistence(timeout: 1) {
chooseOwnPassword.tap()
return
}
let notNow = app.buttons["Not Now"]
if notNow.exists && notNow.isHittable {
notNow.tap()
}
}
// MARK: - Test 1: Complete Authentication Flow
// Mirrors TestIntegration_AuthenticationFlow
func test01_authenticationFlow() {
// Phase 1: Start on login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
if !welcomeText.waitForExistence(timeout: 5) {
ensureLoggedOut()
}
XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should start on login screen")
// Phase 2: Navigate to registration
let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch
XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button should exist")
signUpButton.tap()
sleep(2)
// Phase 3: Fill registration form using proper accessibility identifiers
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Username field should exist")
usernameField.tap()
usernameField.typeText(userAUsername)
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
XCTAssertTrue(emailField.waitForExistence(timeout: 3), "Email field should exist")
emailField.tap()
emailField.typeText(userAEmail)
// Password field - check both SecureField and TextField
var passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
if !passwordField.exists {
passwordField = app.textFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
}
XCTAssertTrue(passwordField.waitForExistence(timeout: 3), "Password field should exist")
passwordField.tap()
dismissStrongPasswordSuggestion()
passwordField.typeText(userAPassword)
// Confirm password field
var confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
if !confirmPasswordField.exists {
confirmPasswordField = app.textFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
}
XCTAssertTrue(confirmPasswordField.waitForExistence(timeout: 3), "Confirm password field should exist")
confirmPasswordField.tap()
dismissStrongPasswordSuggestion()
confirmPasswordField.typeText(userAPassword)
dismissKeyboard()
sleep(1)
// Phase 4: Submit registration
app.swipeUp()
sleep(1)
let registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
XCTAssertTrue(registerButton.waitForExistence(timeout: 5), "Register button should exist")
registerButton.tap()
sleep(3)
// Phase 5: Handle email verification
let verifyEmailTitle = app.staticTexts["Verify Your Email"]
XCTAssertTrue(verifyEmailTitle.waitForExistence(timeout: 10), "Verification screen must appear after registration")
sleep(3)
// Enter verification code - auto-submits when 6 digits entered
let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist")
codeField.tap()
codeField.typeText(verificationCode)
sleep(5)
// Phase 6: Verify logged in
let tabBar = app.tabBars.firstMatch
XCTAssertTrue(tabBar.waitForExistence(timeout: 15), "Should be logged in after registration")
// Phase 7: Logout
UITestHelpers.logout(app: app)
// Phase 8: Login with created credentials
XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be on login screen after logout")
login(username: userAUsername, password: userAPassword)
// Phase 9: Verify logged in
XCTAssertTrue(tabBar.waitForExistence(timeout: 10), "Should be logged in after login")
// Phase 10: Final logout
UITestHelpers.logout(app: app)
XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be logged out")
}
// MARK: - Test 2: Residence CRUD Flow
// Mirrors TestIntegration_ResidenceFlow
func test02_residenceCRUDFlow() {
// Ensure logged in as test user
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
navigateToTab("Residences")
sleep(2)
let residenceName = "E2E Test Home \(timestamp)"
// Phase 1: Create residence
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
XCTAssertTrue(addButton.waitForExistence(timeout: 5), "Add residence button should exist")
addButton.tap()
sleep(2)
// Fill form - just tap and type, don't dismiss keyboard between fields
let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField]
XCTAssertTrue(nameField.waitForExistence(timeout: 5), "Name field should exist")
nameField.tap()
sleep(1)
nameField.typeText(residenceName)
// Use return key to move to next field or dismiss, then scroll
app.keyboards.buttons["return"].tap()
sleep(1)
// Scroll to show more fields
app.swipeUp()
sleep(1)
// Fill street field
let streetField = app.textFields[AccessibilityIdentifiers.Residence.streetAddressField]
if streetField.waitForExistence(timeout: 3) && streetField.isHittable {
streetField.tap()
sleep(1)
streetField.typeText("123 E2E Test St")
app.keyboards.buttons["return"].tap()
sleep(1)
}
// Fill city field
let cityField = app.textFields[AccessibilityIdentifiers.Residence.cityField]
if cityField.waitForExistence(timeout: 3) && cityField.isHittable {
cityField.tap()
sleep(1)
cityField.typeText("Austin")
app.keyboards.buttons["return"].tap()
sleep(1)
}
// Fill state field
let stateField = app.textFields[AccessibilityIdentifiers.Residence.stateProvinceField]
if stateField.waitForExistence(timeout: 3) && stateField.isHittable {
stateField.tap()
sleep(1)
stateField.typeText("TX")
app.keyboards.buttons["return"].tap()
sleep(1)
}
// Fill postal code field
let postalField = app.textFields[AccessibilityIdentifiers.Residence.postalCodeField]
if postalField.waitForExistence(timeout: 3) && postalField.isHittable {
postalField.tap()
sleep(1)
postalField.typeText("78701")
}
// Dismiss keyboard and scroll to save button
dismissKeyboard()
sleep(1)
app.swipeUp()
sleep(1)
// Save the residence
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton]
if saveButton.waitForExistence(timeout: 5) && saveButton.isHittable {
saveButton.tap()
} else {
// Try finding by label as fallback
let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
XCTAssertTrue(saveByLabel.waitForExistence(timeout: 5), "Save button should exist")
saveByLabel.tap()
}
sleep(3)
// Phase 2: Verify residence was created
navigateToTab("Residences")
sleep(2)
let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(residenceName)'")).firstMatch
XCTAssertTrue(residenceCard.waitForExistence(timeout: 10), "Residence '\(residenceName)' should appear in list")
}
// MARK: - Test 3: Task Lifecycle Flow
// Mirrors TestIntegration_TaskFlow
func test03_taskLifecycleFlow() {
// Ensure logged in
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
// Ensure residence exists first - create one if empty
navigateToTab("Residences")
sleep(2)
let residenceCards = app.cells
if residenceCards.count == 0 {
// No residences, create one first
createMinimalResidence(name: "Task Test Home \(timestamp)")
sleep(2)
}
// Navigate to Tasks
navigateToTab("Tasks")
sleep(3)
let taskTitle = "E2E Task Lifecycle \(timestamp)"
// Phase 1: Create task - use firstMatch to avoid multiple element issue
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
guard addButton.waitForExistence(timeout: 5) else {
XCTFail("Add task button should exist")
return
}
// Check if button is enabled
guard addButton.isEnabled else {
XCTFail("Add task button should be enabled (requires at least one residence)")
return
}
addButton.tap()
sleep(2)
// Fill task form
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task title field should exist")
titleField.tap()
sleep(1)
titleField.typeText(taskTitle)
dismissKeyboard()
sleep(1)
app.swipeUp()
sleep(1)
// Save the task
let saveTaskButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
if saveTaskButton.waitForExistence(timeout: 5) && saveTaskButton.isHittable {
saveTaskButton.tap()
} else {
let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add Task' OR label CONTAINS[c] 'Create'")).firstMatch
XCTAssertTrue(saveByLabel.exists, "Save/Create button should exist")
saveByLabel.tap()
}
sleep(3)
// Phase 2: Verify task was created
navigateToTab("Tasks")
sleep(2)
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(taskTitle)'")).firstMatch
XCTAssertTrue(taskCard.waitForExistence(timeout: 10), "Task '\(taskTitle)' should appear in task list")
}
// MARK: - Test 4: Kanban Column Distribution
// Mirrors TestIntegration_TasksByResidenceKanban
func test04_kanbanColumnDistribution() {
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
navigateToTab("Tasks")
sleep(3)
// Verify tasks screen is showing
let tasksTitle = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
let kanbanExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Overdue' OR label CONTAINS[c] 'Upcoming' OR label CONTAINS[c] 'In Progress'")).firstMatch.exists
XCTAssertTrue(kanbanExists || tasksTitle.exists, "Tasks screen should be visible")
}
// MARK: - Test 5: Cross-User Access Control
// Mirrors TestIntegration_CrossUserAccessDenied
func test05_crossUserAccessControl() {
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
// Verify user can access their residences tab
navigateToTab("Residences")
sleep(2)
let residencesVisible = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch.isSelected
XCTAssertTrue(residencesVisible, "User should be able to access Residences tab")
// Verify user can access their tasks tab
navigateToTab("Tasks")
sleep(2)
let tasksAccessible = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch.isSelected
XCTAssertTrue(tasksAccessible, "User should be able to access Tasks tab")
}
// MARK: - Test 6: Lookup Data Endpoints
// Mirrors TestIntegration_LookupEndpoints
func test06_lookupDataAvailable() {
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
// Navigate to add residence to check residence types are loaded
navigateToTab("Residences")
sleep(2)
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
if addButton.waitForExistence(timeout: 5) {
addButton.tap()
sleep(2)
// Check property type picker exists (indicates lookups loaded)
let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type' OR label CONTAINS[c] 'Type'")).firstMatch
let pickerExists = propertyTypePicker.exists
// Cancel form
let cancelButton = app.buttons[AccessibilityIdentifiers.Residence.formCancelButton]
if cancelButton.exists {
cancelButton.tap()
} else {
let cancelByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
if cancelByLabel.exists {
cancelByLabel.tap()
}
}
XCTAssertTrue(pickerExists, "Property type picker should exist (lookups loaded)")
}
}
// MARK: - Test 7: Residence Sharing Flow
// Mirrors TestIntegration_ResidenceSharingFlow
func test07_residenceSharingUIElements() {
UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword)
navigateToTab("Residences")
sleep(2)
// Find any residence to check sharing UI
let residenceCard = app.cells.firstMatch
if residenceCard.waitForExistence(timeout: 5) {
residenceCard.tap()
sleep(2)
// Look for share button in residence details
let shareButton = app.buttons[AccessibilityIdentifiers.Residence.shareButton]
let manageUsersButton = app.buttons[AccessibilityIdentifiers.Residence.manageUsersButton]
// Note: Share functionality may not be visible depending on user permissions
// This test just verifies we can navigate to residence details
// Navigate back
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.exists && backButton.isHittable {
backButton.tap()
sleep(1)
}
}
}
// MARK: - Helper: Create Minimal Residence
private func createMinimalResidence(name: String) {
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
guard addButton.waitForExistence(timeout: 5) else { return }
addButton.tap()
sleep(2)
// Fill name field
let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField]
if nameField.waitForExistence(timeout: 5) {
nameField.tap()
sleep(1)
nameField.typeText(name)
app.keyboards.buttons["return"].tap()
sleep(1)
}
// Scroll to show address fields
app.swipeUp()
sleep(1)
// Fill street field
let streetField = app.textFields[AccessibilityIdentifiers.Residence.streetAddressField]
if streetField.waitForExistence(timeout: 3) && streetField.isHittable {
streetField.tap()
sleep(1)
streetField.typeText("123 Test St")
app.keyboards.buttons["return"].tap()
sleep(1)
}
// Fill city field
let cityField = app.textFields[AccessibilityIdentifiers.Residence.cityField]
if cityField.waitForExistence(timeout: 3) && cityField.isHittable {
cityField.tap()
sleep(1)
cityField.typeText("Austin")
app.keyboards.buttons["return"].tap()
sleep(1)
}
// Fill state field
let stateField = app.textFields[AccessibilityIdentifiers.Residence.stateProvinceField]
if stateField.waitForExistence(timeout: 3) && stateField.isHittable {
stateField.tap()
sleep(1)
stateField.typeText("TX")
app.keyboards.buttons["return"].tap()
sleep(1)
}
// Fill postal code field
let postalField = app.textFields[AccessibilityIdentifiers.Residence.postalCodeField]
if postalField.waitForExistence(timeout: 3) && postalField.isHittable {
postalField.tap()
sleep(1)
postalField.typeText("78701")
}
dismissKeyboard()
sleep(1)
app.swipeUp()
sleep(1)
// Save
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton]
if saveButton.waitForExistence(timeout: 5) && saveButton.isHittable {
saveButton.tap()
} else {
let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
if saveByLabel.exists {
saveByLabel.tap()
}
}
sleep(3)
}
// MARK: - Helper: Find Add Task Button
private func findAddTaskButton() -> XCUIElement {
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
if addButton.exists {
return addButton
}
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task' OR label CONTAINS[c] 'New Task'")).firstMatch
}
}

View File

@@ -0,0 +1,64 @@
import XCTest
/// Centralized app launch configuration for UI tests.
///
/// Provides consistent launch arguments and environment variables across
/// all test suites. Disables animations and sets locale to English for
/// deterministic test behavior.
enum TestLaunchConfig {
/// Standard launch arguments for UI test mode.
/// Disables animations and forces English locale.
static let standardArguments: [String] = [
"-UITEST_MODE", "1",
"-AppleLanguages", "(en)",
"-AppleLocale", "en_US",
"-UIAnimationsEnabled", "NO"
]
/// Launch environment variables for UI tests.
static let standardEnvironment: [String: String] = [
"UITEST_MODE": "1",
"ANIMATIONS_DISABLED": "1"
]
/// Configure and launch app with standard test settings.
///
/// - Parameters:
/// - additionalArguments: Extra launch arguments to append.
/// - additionalEnvironment: Extra environment variables to merge.
/// - Returns: The launched `XCUIApplication` instance.
@discardableResult
static func launchApp(
additionalArguments: [String] = [],
additionalEnvironment: [String: String] = [:]
) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = standardArguments + additionalArguments
var env = standardEnvironment
additionalEnvironment.forEach { env[$0.key] = $0.value }
app.launchEnvironment = env
app.launch()
return app
}
/// Launch app pre-authenticated (skips login flow).
///
/// Passes test credentials via launch arguments and environment so the
/// app can bypass the normal authentication flow during UI tests.
///
/// - Parameters:
/// - email: Test user email address.
/// - token: Test authentication token.
/// - Returns: The launched `XCUIApplication` instance.
@discardableResult
static func launchAuthenticated(
email: String = "test@example.com",
token: String = "test-token-12345"
) -> XCUIApplication {
return launchApp(
additionalArguments: ["-TEST_AUTH_EMAIL", email, "-TEST_AUTH_TOKEN", token],
additionalEnvironment: ["TEST_AUTH_EMAIL": email, "TEST_AUTH_TOKEN": token]
)
}
}

View File

@@ -0,0 +1,83 @@
import XCTest
final class AccessibilityTests: BaseUITestCase {
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")
}
}

View File

@@ -0,0 +1,19 @@
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)
}
}

View File

@@ -0,0 +1,133 @@
import XCTest
final class AuthenticationTests: BaseUITestCase {
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 register = TestFlows.openRegisterFromLogin(app: app)
register.tapCancel()
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
}
func testF204_RegisterFormAcceptsInput() {
let register = TestFlows.openRegisterFromLogin(app: app)
register.waitForLoad(timeout: defaultTimeout)
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists)
}
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")
}
// MARK: - Additional Authentication Coverage
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 accessible")
}
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 register = TestFlows.openRegisterFromLogin(app: app)
register.waitForLoad(timeout: defaultTimeout)
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 that tapping forgot password transitions away from login
// The forgot password screen should appear (either sheet or navigation)
let forgotPasswordAppeared = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Forgot' OR label CONTAINS[c] 'Reset' OR label CONTAINS[c] 'Password'")
).firstMatch.waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(forgotPasswordAppeared, "Forgot password flow should appear after tapping button")
}
// MARK: - AUTH-005: Invalid token at startup clears session and returns to login
func test08_invalidatedTokenRedirectsToLogin() throws {
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
// Create a verified account via API
guard let session = TestAccountManager.createVerifiedAccount() else {
XCTFail("Could not create verified test account")
return
}
// Login via UI
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
TestFlows.loginWithCredentials(app: app, username: session.username, password: session.password)
// Wait until the main tab bar is visible, confirming successful login
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
XCTAssertTrue(
mainTabs.waitForExistence(timeout: longTimeout),
"Expected main tabs after login"
)
// Invalidate the token via the logout API (simulates a server-side token revocation)
TestAccountManager.invalidateToken(session)
// Force restart the app terminate and relaunch without --reset-state so the
// app restores its persisted session, which should then be rejected by the server.
app.terminate()
app.launchArguments = ["--ui-testing", "--disable-animations"]
app.launch()
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: defaultTimeout)
// The app should detect the invalid token and redirect to the login screen
let usernameField = app.textFields[UITestID.Auth.usernameField]
XCTAssertTrue(
usernameField.waitForExistence(timeout: longTimeout),
"Expected login screen after startup with an invalidated token"
)
}
}

View File

@@ -0,0 +1,215 @@
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: AuthenticatedTestCase {
override var useSeededAccount: Bool { true }
// MARK: - CON-002: Create Contractor
func testCON002_CreateContractorMinimalFields() {
navigateToContractors()
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton]
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)
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
saveButton.forceTap()
let newContractor = app.staticTexts[uniqueName]
XCTAssertTrue(
newContractor.waitForExistence(timeout: longTimeout),
"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()
// Find and tap the seeded contractor
let card = app.staticTexts[contractor.name]
card.waitForExistenceOrFail(timeout: longTimeout)
card.forceTap()
// Tap edit
let editButton = app.buttons[AccessibilityIdentifiers.Contractor.editButton]
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
editButton.forceTap()
// Update name
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
nameField.forceTap()
nameField.press(forDuration: 1.0)
let selectAll = app.menuItems["Select All"]
if selectAll.waitForExistence(timeout: 2) {
selectAll.tap()
}
let updatedName = "Updated Contractor \(Int(Date().timeIntervalSince1970))"
nameField.typeText(updatedName)
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
saveButton.forceTap()
let updatedText = app.staticTexts[updatedName]
XCTAssertTrue(
updatedText.waitForExistence(timeout: longTimeout),
"Updated contractor name should appear after edit"
)
}
// MARK: - CON-007: Favorite Toggle
func test20_toggleContractorFavorite() {
// Seed a contractor via API and track it for cleanup
let contractor = cleaner.seedContractor(name: "Favorite Toggle Contractor \(Int(Date().timeIntervalSince1970))")
navigateToContractors()
// Find and open the seeded contractor
let card = app.staticTexts[contractor.name]
card.waitForExistenceOrFail(timeout: longTimeout)
card.forceTap()
// Look for a favorite / star button in the detail view.
// The button may be labelled "Favorite", carry a star SF symbol, or use a toggle.
let favoriteButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Favorite' OR label CONTAINS[c] 'Star' OR label CONTAINS[c] 'favourite'")
).firstMatch
guard favoriteButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Favorite/star button not found on contractor detail view")
return
}
// Capture initial accessibility value / label to detect change
let initialLabel = favoriteButton.label
// First toggle mark as favourite
favoriteButton.forceTap()
// Brief pause so the UI can settle after the API call
_ = app.staticTexts.firstMatch.waitForExistence(timeout: 2)
// The button's label or selected state should have changed
let afterFirstToggleLabel = favoriteButton.label
XCTAssertNotEqual(
initialLabel, afterFirstToggleLabel,
"Favorite button appearance should change after first toggle"
)
// Second toggle un-mark as favourite, state should return to original
favoriteButton.forceTap()
_ = app.staticTexts.firstMatch.waitForExistence(timeout: 2)
let afterSecondToggleLabel = favoriteButton.label
XCTAssertEqual(
initialLabel, afterSecondToggleLabel,
"Favorite button appearance should return to original after second toggle"
)
}
// MARK: - CON-008: Contractor by Residence Filter
func test21_contractorByResidenceFilter() throws {
// Seed a residence and a contractor linked to it
let residence = cleaner.seedResidence(name: "Filter Test Residence \(Int(Date().timeIntervalSince1970))")
let contractor = cleaner.seedContractor(
name: "Residence Contractor \(Int(Date().timeIntervalSince1970))",
fields: ["residence_id": residence.id]
)
navigateToResidences()
// Open the seeded residence's detail view
let residenceText = app.staticTexts[residence.name]
residenceText.waitForExistenceOrFail(timeout: longTimeout)
residenceText.forceTap()
// Look for a Contractors section within the residence detail.
// The section header text or accessibility element is checked first.
let contractorsSectionHeader = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Contractor'")
).firstMatch
guard contractorsSectionHeader.waitForExistence(timeout: defaultTimeout) else {
throw XCTSkip("Residence detail does not expose a Contractors section — skipping filter test")
}
// Verify the seeded contractor appears in the residence's contractor list
let contractorEntry = app.staticTexts[contractor.name]
XCTAssertTrue(
contractorEntry.waitForExistence(timeout: defaultTimeout),
"Contractor '\(contractor.name)' should appear in the contractors section of residence '\(residence.name)'"
)
}
// MARK: - CON-006: Delete Contractor
func testCON006_DeleteContractor() {
// Seed a contractor via API don't track since we'll delete through UI
let deleteName = "Delete Contractor \(Int(Date().timeIntervalSince1970))"
TestDataSeeder.createContractor(token: session.token, name: deleteName)
navigateToContractors()
let target = app.staticTexts[deleteName]
target.waitForExistenceOrFail(timeout: longTimeout)
target.forceTap()
let deleteButton = app.buttons[AccessibilityIdentifiers.Contractor.deleteButton]
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
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: shortTimeout) {
confirmButton.tap()
} else if alertDelete.waitForExistence(timeout: shortTimeout) {
alertDelete.tap()
}
let deletedContractor = app.staticTexts[deleteName]
XCTAssertTrue(
deletedContractor.waitForNonExistence(timeout: longTimeout),
"Deleted contractor should no longer appear"
)
}
}

View File

@@ -0,0 +1,894 @@
import XCTest
/// Integration tests for the data layer covering caching, ETag, logout cleanup, persistence, and lookup consistency.
///
/// Test Plan IDs: DATA-001 through DATA-007.
/// All tests run against the real local backend via `AuthenticatedTestCase`.
final class DataLayerTests: AuthenticatedTestCase {
override var useSeededAccount: Bool { true }
/// Don't reset state by default individual tests override when needed.
override var includeResetStateLaunchArgument: Bool { false }
// MARK: - DATA-001: Lookups Initialize After Login
func testDATA001_LookupsInitializeAfterLogin() {
// After AuthenticatedTestCase.setUp, the app is logged in and on main tabs.
// Navigate to tasks and open the create form to verify pickers are populated.
navigateToTasks()
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
guard addButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Tasks add button not found after login")
return
}
addButton.forceTap()
// Verify that the category picker exists and is populated
let categoryPicker = app.buttons[AccessibilityIdentifiers.Task.categoryPicker]
.exists ? app.buttons[AccessibilityIdentifiers.Task.categoryPicker]
: app.otherElements[AccessibilityIdentifiers.Task.categoryPicker]
XCTAssertTrue(
categoryPicker.waitForExistence(timeout: defaultTimeout),
"Category picker should exist in task form, indicating lookups loaded"
)
// Verify priority picker exists
let priorityPicker = app.buttons[AccessibilityIdentifiers.Task.priorityPicker]
.exists ? app.buttons[AccessibilityIdentifiers.Task.priorityPicker]
: app.otherElements[AccessibilityIdentifiers.Task.priorityPicker]
XCTAssertTrue(
priorityPicker.waitForExistence(timeout: defaultTimeout),
"Priority picker should exist in task form, indicating lookups loaded"
)
// Verify residence picker exists (needs at least one residence)
let residencePicker = app.buttons[AccessibilityIdentifiers.Task.residencePicker]
.exists ? app.buttons[AccessibilityIdentifiers.Task.residencePicker]
: app.otherElements[AccessibilityIdentifiers.Task.residencePicker]
XCTAssertTrue(
residencePicker.waitForExistence(timeout: defaultTimeout),
"Residence picker should exist in task form, indicating residences loaded"
)
// Verify frequency picker exists proves all lookup types loaded
let frequencyPicker = app.buttons[AccessibilityIdentifiers.Task.frequencyPicker]
.exists ? app.buttons[AccessibilityIdentifiers.Task.frequencyPicker]
: app.otherElements[AccessibilityIdentifiers.Task.frequencyPicker]
XCTAssertTrue(
frequencyPicker.waitForExistence(timeout: defaultTimeout),
"Frequency picker should exist in task form, indicating lookups loaded"
)
// Tap category picker to verify it has options (not empty)
if categoryPicker.isHittable {
categoryPicker.forceTap()
// Look for picker options - any text that's NOT the placeholder
let pickerOptions = app.staticTexts.allElementsBoundByIndex
let hasOptions = pickerOptions.contains { element in
element.exists && !element.label.isEmpty
}
XCTAssertTrue(hasOptions, "Category picker should have options after lookups initialize")
// Dismiss picker if needed
let doneButton = app.buttons["Done"]
if doneButton.exists && doneButton.isHittable {
doneButton.tap()
} else {
// Tap outside to dismiss
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
}
}
cancelTaskForm()
}
// MARK: - DATA-002: ETag Refresh Handles 304
func testDATA002_ETagRefreshHandles304() {
// Verify that a second visit to a lookup-dependent form still shows data.
// If ETag / 304 handling were broken, the second load would show empty pickers.
// First: verify lookups are loaded via the static_data endpoint
// The API returns an ETag header, and the app stores it for conditional requests.
verifyStaticDataEndpointSupportsETag()
// Open task form verify pickers populated close
navigateToTasks()
openTaskForm()
assertTaskFormPickersPopulated()
cancelTaskForm()
// Navigate away and back triggers a cache check.
// The app will send If-None-Match with the stored ETag.
// Backend returns 304, app keeps cached lookups.
navigateToResidences()
sleep(1)
navigateToTasks()
// Open form again and verify pickers still populated (304 path worked)
openTaskForm()
assertTaskFormPickersPopulated()
cancelTaskForm()
}
// MARK: - DATA-003: Legacy Fallback When Seeded Endpoint Unavailable
func testDATA003_LegacyFallbackStillLoadsCoreLookups() throws {
// The app uses /api/static_data/ as the primary seeded endpoint.
// If it fails, there's a fallback that still loads core lookup types.
// We can't break the endpoint in a UI test, but we CAN verify the
// core lookups are available from BOTH the primary and fallback endpoints.
// Verify the primary endpoint is reachable
let primaryResult = TestAccountAPIClient.rawRequest(method: "GET", path: "/static_data/")
XCTAssertTrue(
primaryResult.succeeded,
"Primary static_data endpoint should be reachable (status \(primaryResult.statusCode))"
)
// Verify the response contains all required lookup types
guard let data = primaryResult.data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
XCTFail("Could not parse static_data response")
return
}
let requiredKeys = ["residence_types", "task_categories", "task_priorities", "task_frequencies", "contractor_specialties"]
for key in requiredKeys {
guard let array = json[key] as? [[String: Any]], !array.isEmpty else {
XCTFail("static_data response missing or empty '\(key)'")
continue
}
// Verify each item has an 'id' and 'name' for map building
let firstItem = array[0]
XCTAssertNotNil(firstItem["id"], "\(key) items should have 'id' for associateBy")
XCTAssertNotNil(firstItem["name"], "\(key) items should have 'name' for display")
}
// Verify lookups are populated in the app UI (proves the app loaded them)
navigateToTasks()
openTaskForm()
assertTaskFormPickersPopulated()
// Also verify contractor specialty picker in contractor form
cancelTaskForm()
navigateToContractors()
let contractorAddButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton]
let contractorEmptyState = app.otherElements[AccessibilityIdentifiers.Contractor.emptyStateView]
let contractorList = app.otherElements[AccessibilityIdentifiers.Contractor.contractorsList]
let contractorLoaded = contractorAddButton.waitForExistence(timeout: defaultTimeout)
|| contractorEmptyState.waitForExistence(timeout: 3)
|| contractorList.waitForExistence(timeout: 3)
XCTAssertTrue(contractorLoaded, "Contractors screen should load")
if contractorAddButton.exists && contractorAddButton.isHittable {
contractorAddButton.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 specialtyPicker = app.buttons[AccessibilityIdentifiers.Contractor.specialtyPicker]
.exists ? app.buttons[AccessibilityIdentifiers.Contractor.specialtyPicker]
: app.otherElements[AccessibilityIdentifiers.Contractor.specialtyPicker]
XCTAssertTrue(
specialtyPicker.waitForExistence(timeout: defaultTimeout),
"Contractor specialty picker should exist, proving contractor_specialties loaded"
)
let contractorCancelButton = app.buttons[AccessibilityIdentifiers.Contractor.formCancelButton]
if contractorCancelButton.exists && contractorCancelButton.isHittable {
contractorCancelButton.forceTap()
}
}
// MARK: - DATA-004: Cache Timeout and Force Refresh
func testDATA004_CacheTimeoutAndForceRefresh() {
// Seed data via API so we have something to verify in the cache
let residence = cleaner.seedResidence(name: "Cache Test \(Int(Date().timeIntervalSince1970))")
// Navigate to residences data should appear from cache or initial load
navigateToResidences()
let residenceText = app.staticTexts[residence.name]
XCTAssertTrue(
residenceText.waitForExistence(timeout: longTimeout),
"Seeded residence should appear in list (initial cache load)"
)
// Navigate away and back cached data should still be available immediately
navigateToTasks()
sleep(1)
navigateToResidences()
XCTAssertTrue(
residenceText.waitForExistence(timeout: defaultTimeout),
"Seeded residence should still appear after tab switch (data served from cache)"
)
// Seed a second residence via API while we're on the residences tab
let residence2 = cleaner.seedResidence(name: "Cache Test 2 \(Int(Date().timeIntervalSince1970))")
// Without refresh, the new residence may not appear (stale cache)
// Pull-to-refresh should force a fresh fetch
let scrollView = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
let listElement = scrollView.exists ? scrollView : app.otherElements[AccessibilityIdentifiers.Residence.residencesList]
// Perform pull-to-refresh gesture
let start = listElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
let finish = listElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
start.press(forDuration: 0.1, thenDragTo: finish)
let residence2Text = app.staticTexts[residence2.name]
XCTAssertTrue(
residence2Text.waitForExistence(timeout: longTimeout),
"Second residence should appear after pull-to-refresh (forced fresh fetch)"
)
}
// MARK: - DATA-005: Cache Invalidation on Logout
func testDATA005_LogoutClearsUserDataButRetainsTheme() {
// Seed data so there's something to clear
let residence = cleaner.seedResidence(name: "Logout Test \(Int(Date().timeIntervalSince1970))")
let _ = cleaner.seedTask(residenceId: residence.id, title: "Logout Task \(Int(Date().timeIntervalSince1970))")
// Verify data is visible
navigateToResidences()
let residenceText = app.staticTexts[residence.name]
XCTAssertTrue(
residenceText.waitForExistence(timeout: longTimeout),
"Seeded data should be visible before logout"
)
// Perform logout via UI
performLogout()
// Verify we're on login screen (user data cleared, session invalidated)
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(
usernameField.waitForExistence(timeout: longTimeout),
"Should be on login screen after logout"
)
// Verify main tabs are NOT accessible (data cleared)
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
XCTAssertFalse(mainTabs.exists, "Main app should not be accessible after logout")
// Re-login with the same seeded account
loginViaUI()
// After re-login, the seeded residence should still exist on backend
// but this proves the app fetched fresh data, not stale cache
navigateToResidences()
// The seeded residence from this test should appear (it's on the backend)
XCTAssertTrue(
residenceText.waitForExistence(timeout: longTimeout),
"Data should reload after re-login (fresh fetch, not stale cache)"
)
}
// MARK: - DATA-006: Disk Persistence After App Restart
func testDATA006_LookupsPersistAfterAppRestart() {
// Verify lookups are loaded
navigateToTasks()
openTaskForm()
assertTaskFormPickersPopulated()
cancelTaskForm()
// Terminate and relaunch the app
app.terminate()
// Relaunch WITHOUT --reset-state so persisted data survives
app.launchArguments = [
"--ui-testing",
"--disable-animations"
]
app.launch()
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: defaultTimeout)
// The app may need re-login (token persisted) or go to onboarding.
// If we land on main tabs, lookups should be available from disk.
// If we land on login, log in and then check.
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
let tabBar = app.tabBars.firstMatch
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
let onboardingRoot = app.otherElements[UITestID.Root.onboarding]
let deadline = Date().addingTimeInterval(longTimeout)
while Date() < deadline {
if mainTabs.exists || tabBar.exists {
break
}
if usernameField.exists {
// Need to re-login
loginViaUI()
break
}
if onboardingRoot.exists {
// Navigate to login from onboarding
let loginButton = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
if loginButton.waitForExistence(timeout: 5) {
loginButton.forceTap()
}
if usernameField.waitForExistence(timeout: 10) {
loginViaUI()
}
break
}
// Handle email verification gate
let verificationScreen = VerificationScreen(app: app)
if verificationScreen.codeField.exists {
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
verificationScreen.submitCode()
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
// Wait for main app
let reachedMain = mainTabs.waitForExistence(timeout: longTimeout)
|| tabBar.waitForExistence(timeout: 5)
XCTAssertTrue(reachedMain, "Should reach main app after restart")
// After restart + potential re-login, lookups should be available
// (either from disk persistence or fresh fetch after login)
navigateToTasks()
openTaskForm()
assertTaskFormPickersPopulated()
cancelTaskForm()
}
// MARK: - DATA-007: Lookup Map/List Consistency
func testDATA007_LookupMapListConsistency() throws {
// Verify that lookup data from the API has consistent IDs across all types
// and that these IDs match what the app displays in pickers.
// Fetch the raw static_data from the backend
let result = TestAccountAPIClient.rawRequest(method: "GET", path: "/static_data/")
XCTAssertTrue(result.succeeded, "static_data endpoint should return 200")
guard let data = result.data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
XCTFail("Could not parse static_data response")
return
}
// Verify each lookup type has unique IDs (no duplicates)
let lookupKeys = [
"residence_types",
"task_categories",
"task_priorities",
"task_frequencies",
"contractor_specialties"
]
for key in lookupKeys {
guard let items = json[key] as? [[String: Any]] else {
XCTFail("Missing '\(key)' in static_data")
continue
}
// Extract IDs
let ids = items.compactMap { $0["id"] as? Int }
XCTAssertEqual(ids.count, items.count, "\(key): every item should have an integer 'id'")
// Verify unique IDs (would break associateBy)
let uniqueIds = Set(ids)
XCTAssertEqual(
uniqueIds.count, ids.count,
"\(key): all IDs should be unique (found \(ids.count - uniqueIds.count) duplicates)"
)
// Verify every item has a non-empty name
let names = items.compactMap { $0["name"] as? String }
XCTAssertEqual(names.count, items.count, "\(key): every item should have a 'name'")
for name in names {
XCTAssertFalse(name.isEmpty, "\(key): no item should have an empty name")
}
}
// Verify the app's pickers reflect the API data by checking task form
navigateToTasks()
openTaskForm()
// Count the number of categories from the API
let apiCategories = (json["task_categories"] as? [[String: Any]])?.count ?? 0
XCTAssertGreaterThan(apiCategories, 0, "API should have task categories")
// Verify category picker has selectable options
let categoryPicker = findPicker(AccessibilityIdentifiers.Task.categoryPicker)
if categoryPicker.isHittable {
categoryPicker.forceTap()
sleep(1)
// Count visible category options
let pickerTexts = app.staticTexts.allElementsBoundByIndex.filter {
$0.exists && !$0.label.isEmpty && $0.label != "Category"
}
XCTAssertGreaterThan(
pickerTexts.count, 0,
"Category picker should have options matching API data"
)
// Dismiss picker
dismissPicker()
}
// Verify priority picker has the expected number of priorities
let apiPriorities = (json["task_priorities"] as? [[String: Any]])?.count ?? 0
XCTAssertGreaterThan(apiPriorities, 0, "API should have task priorities")
let priorityPicker = findPicker(AccessibilityIdentifiers.Task.priorityPicker)
if priorityPicker.isHittable {
priorityPicker.forceTap()
sleep(1)
let priorityTexts = app.staticTexts.allElementsBoundByIndex.filter {
$0.exists && !$0.label.isEmpty && $0.label != "Priority"
}
XCTAssertGreaterThan(
priorityTexts.count, 0,
"Priority picker should have options matching API data"
)
dismissPicker()
}
cancelTaskForm()
}
// MARK: - DATA-006 (UI): Disk Persistence Preserves Lookups After App Restart
/// test08: DATA-006 Lookups and current user reload correctly after a real app restart.
///
/// Terminates the app and relaunches without `--reset-state` so persisted data
/// survives. After re-login the task pickers must still be populated, proving that
/// the disk persistence layer successfully seeded the in-memory DataManager.
func test08_diskPersistencePreservesLookupsAfterRestart() {
// Step 1: Verify lookups are loaded before the restart
navigateToTasks()
openTaskForm()
assertTaskFormPickersPopulated()
cancelTaskForm()
// Step 2: Terminate the app persisted data should survive on disk
app.terminate()
// Step 3: Relaunch WITHOUT --reset-state so the on-disk cache is preserved
app.launchArguments = [
"--ui-testing",
"--disable-animations"
// Intentionally omitting --reset-state
]
app.launch()
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: defaultTimeout)
// Step 4: Handle whatever landing screen the app shows after restart.
// The token may have persisted (main tabs) or expired (login screen).
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
let tabBar = app.tabBars.firstMatch
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
let onboardingRoot = app.otherElements[UITestID.Root.onboarding]
let deadline = Date().addingTimeInterval(longTimeout)
while Date() < deadline {
if mainTabs.exists || tabBar.exists {
break
}
if usernameField.exists {
loginViaUI()
break
}
if onboardingRoot.exists {
let loginBtn = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
if loginBtn.waitForExistence(timeout: 5) {
loginBtn.forceTap()
}
if usernameField.waitForExistence(timeout: 10) {
loginViaUI()
}
break
}
// Handle email verification gate (new accounts only seeded account is pre-verified)
let verificationScreen = VerificationScreen(app: app)
if verificationScreen.codeField.exists {
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
verificationScreen.submitCode()
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
let reachedMain = mainTabs.waitForExistence(timeout: longTimeout)
|| tabBar.waitForExistence(timeout: 5)
XCTAssertTrue(reachedMain, "Should reach main app after restart and potential re-login")
// Step 5: After restart + potential re-login, lookups must still be available.
// If disk persistence works, the DataManager is seeded from disk before the
// first login-triggered fetch completes, so pickers appear immediately.
navigateToTasks()
openTaskForm()
assertTaskFormPickersPopulated()
cancelTaskForm()
}
// MARK: - THEME-001: Theme Persistence via UI
/// test09: THEME-001 Theme choice persists across app restarts.
///
/// Navigates to the profile tab, checks for theme-related settings, optionally
/// selects a non-default theme, then restarts the app and verifies the profile
/// screen still loads (confirming the theme setting did not cause a crash and
/// persisted state is coherent).
func test09_themePersistsAcrossRestart() {
// Step 1: Navigate to the profile tab and confirm it loads
navigateToProfile()
let profileView = app.otherElements[AccessibilityIdentifiers.Navigation.settingsButton]
// The profile screen should be accessible via the profile tab
let profileLoaded = profileView.waitForExistence(timeout: defaultTimeout)
|| app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Profile' OR label CONTAINS[c] 'Account'")
).firstMatch.waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(profileLoaded, "Profile/settings screen should load after tapping profile tab")
// Step 2: Look for a theme picker button in the profile/settings UI.
// The exact identifier depends on implementation check for common patterns.
let themeButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Theme' OR label CONTAINS[c] 'Appearance' OR label CONTAINS[c] 'Color'")
).firstMatch
var selectedThemeName: String? = nil
if themeButton.waitForExistence(timeout: shortTimeout) && themeButton.isHittable {
themeButton.forceTap()
sleep(1)
// Look for theme options in any picker/sheet that appears
// Try to select a theme that is NOT the currently selected one
let themeOptions = app.buttons.allElementsBoundByIndex.filter { button in
button.exists && button.isHittable &&
button.label != "Theme" && button.label != "Appearance" &&
!button.label.isEmpty && button.label != "Cancel" && button.label != "Done"
}
if let firstOption = themeOptions.first {
selectedThemeName = firstOption.label
firstOption.forceTap()
sleep(1)
}
// Dismiss the theme picker if still visible
let doneButton = app.buttons["Done"]
if doneButton.exists && doneButton.isHittable {
doneButton.tap()
} else {
let cancelButton = app.buttons["Cancel"]
if cancelButton.exists && cancelButton.isHittable {
cancelButton.tap()
}
}
}
// Step 3: Terminate and relaunch without --reset-state
app.terminate()
app.launchArguments = [
"--ui-testing",
"--disable-animations"
// Intentionally omitting --reset-state to preserve theme setting
]
app.launch()
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: defaultTimeout)
// Step 4: Re-login if needed
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
let tabBar = app.tabBars.firstMatch
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
let onboardingRoot = app.otherElements[UITestID.Root.onboarding]
let deadline = Date().addingTimeInterval(longTimeout)
while Date() < deadline {
if mainTabs.exists || tabBar.exists { break }
if usernameField.exists { loginViaUI(); break }
if onboardingRoot.exists {
let loginBtn = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
if loginBtn.waitForExistence(timeout: 5) { loginBtn.forceTap() }
if usernameField.waitForExistence(timeout: 10) { loginViaUI() }
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
let reachedMain = mainTabs.waitForExistence(timeout: longTimeout)
|| tabBar.waitForExistence(timeout: 5)
XCTAssertTrue(reachedMain, "Should reach main app after restart")
// Step 5: Navigate to profile again and confirm the screen loads.
// If the theme setting is persisted and applied without errors, the app
// renders the profile tab correctly.
navigateToProfile()
let profileReloaded = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Profile' OR label CONTAINS[c] 'Account' OR label CONTAINS[c] 'Settings'")
).firstMatch.waitForExistence(timeout: defaultTimeout)
|| app.otherElements.containing(
NSPredicate(format: "identifier CONTAINS[c] 'Profile' OR identifier CONTAINS[c] 'Settings'")
).firstMatch.exists
XCTAssertTrue(
profileReloaded,
"Profile/settings screen should load after restart with persisted theme — " +
"confirming the theme state ('\(selectedThemeName ?? "default")') did not cause a crash"
)
// If we successfully selected a theme, try to verify it's still reflected in the UI
if let themeName = selectedThemeName {
let themeStillVisible = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] %@", themeName)
).firstMatch.exists
// Non-fatal: theme picker UI varies; just log the result
if themeStillVisible {
// Theme label is visible persistence confirmed at UI level
XCTAssertTrue(true, "Theme '\(themeName)' is still visible in settings after restart")
}
// If not visible, the theme may have been applied silently the lack of crash is the pass criterion
}
}
// MARK: - TCOMP-004: Completion History
/// TCOMP-004 History list loads for a task and is sorted correctly.
///
/// Seeds a task, marks it complete via API (if the endpoint exists), then opens
/// the task detail to look for a completion history section. If the task completion
/// endpoint is not available in `TestAccountAPIClient`, the test documents this
/// gap and exercises the task detail view at minimum.
func test10_completionHistoryLoadsAndIsSorted() throws {
// Seed a residence and task via API
let residence = cleaner.seedResidence(name: "TCOMP004 Residence \(Int(Date().timeIntervalSince1970))")
let task = cleaner.seedTask(residenceId: residence.id, title: "TCOMP004 Task \(Int(Date().timeIntervalSince1970))")
// Attempt to mark the task as complete via the mark-in-progress endpoint first,
// then look for a complete action. The completeTask endpoint is not yet in
// TestAccountAPIClient document this and proceed with what is available.
//
// NOTE: If a POST /tasks/{id}/complete/ endpoint is added to TestAccountAPIClient,
// call it here to seed a completion record before opening the task detail.
let markedInProgress = TestAccountAPIClient.markTaskInProgress(token: session.token, id: task.id)
// Completion via API not yet implemented in TestAccountAPIClient see TCOMP-004 stub note.
// Navigate to tasks and open the seeded task
navigateToTasks()
let taskText = app.staticTexts[task.title]
guard taskText.waitForExistence(timeout: longTimeout) else {
throw XCTSkip("Seeded task '\(task.title)' not visible in current view — may require filter toggle")
}
taskText.forceTap()
// Verify the task detail view loaded
let detailView = app.otherElements[AccessibilityIdentifiers.Task.detailView]
let taskDetailLoaded = detailView.waitForExistence(timeout: defaultTimeout)
|| app.staticTexts[task.title].waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(taskDetailLoaded, "Task detail view should load after tapping the task")
// Look for a completion history section.
// The identifier pattern mirrors the codebase convention used in AccessibilityIdentifiers.
let historySection = app.otherElements.containing(
NSPredicate(format: "identifier CONTAINS[c] 'History' OR identifier CONTAINS[c] 'Completion'")
).firstMatch
let historyText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'History' OR label CONTAINS[c] 'Completed' OR label CONTAINS[c] 'completion'")
).firstMatch
if historySection.waitForExistence(timeout: shortTimeout) || historyText.waitForExistence(timeout: shortTimeout) {
// History section is visible verify at least one entry if the task was completed
if markedInProgress != nil {
// The task was set in-progress; a full completion record requires the complete endpoint.
// Assert the history section is accessible (not empty or crashed).
XCTAssertTrue(
historySection.exists || historyText.exists,
"Completion history section should be present in task detail"
)
}
} else {
// NOTE: If this assertion fails, the task detail may not yet expose a completion
// history section in the UI. The TCOMP-004 test plan item requires:
// 1. POST /tasks/{id}/complete/ endpoint in TestAccountAPIClient
// 2. A completion history accessibility identifier in AccessibilityIdentifiers.Task
// 3. The SwiftUI task detail view to expose that section with an accessibility id
// Until all three are implemented, skip rather than fail hard.
throw XCTSkip(
"TCOMP-004: No completion history section found in task detail. " +
"This test requires: (1) TestAccountAPIClient.completeTask() endpoint, " +
"(2) AccessibilityIdentifiers.Task.completionHistorySection, and " +
"(3) the SwiftUI detail view to expose the history list with that identifier."
)
}
}
// MARK: - Helpers
/// Open the task creation form.
private func openTaskForm() {
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
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()
}
// Wait for form to be ready
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task form should appear")
}
/// Cancel/dismiss the task form.
private func cancelTaskForm() {
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton]
if cancelButton.exists && cancelButton.isHittable {
cancelButton.forceTap()
}
}
/// Assert all four core task form pickers are populated.
private func assertTaskFormPickersPopulated(file: StaticString = #filePath, line: UInt = #line) {
let pickerIds = [
("Category", AccessibilityIdentifiers.Task.categoryPicker),
("Priority", AccessibilityIdentifiers.Task.priorityPicker),
("Frequency", AccessibilityIdentifiers.Task.frequencyPicker),
("Residence", AccessibilityIdentifiers.Task.residencePicker)
]
for (name, identifier) in pickerIds {
let picker = findPicker(identifier)
XCTAssertTrue(
picker.waitForExistence(timeout: defaultTimeout),
"\(name) picker should exist, indicating lookups loaded",
file: file,
line: line
)
}
}
/// Find a picker element that may be a button or otherElement.
private func findPicker(_ identifier: String) -> XCUIElement {
let asButton = app.buttons[identifier]
if asButton.exists { return asButton }
return app.otherElements[identifier]
}
/// Dismiss an open picker overlay.
private func dismissPicker() {
let doneButton = app.buttons["Done"]
if doneButton.exists && doneButton.isHittable {
doneButton.tap()
} else {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
}
}
/// Perform logout via the UI (settings logout confirm).
private func performLogout() {
// Navigate to Residences tab (where settings button lives)
navigateToResidences()
sleep(1)
// Tap settings button
let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
settingsButton.waitForExistenceOrFail(timeout: defaultTimeout)
settingsButton.forceTap()
sleep(1)
// Scroll to and tap logout button
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
if !logoutButton.waitForExistence(timeout: defaultTimeout) {
// Try scrolling to find it
let scrollView = app.scrollViews.firstMatch
if scrollView.exists {
logoutButton.scrollIntoView(in: scrollView)
}
}
logoutButton.forceTap()
sleep(1)
// Confirm logout in alert
let alert = app.alerts.firstMatch
if alert.waitForExistence(timeout: shortTimeout) {
let confirmLogout = alert.buttons["Log Out"]
if confirmLogout.exists {
confirmLogout.tap()
} else {
// Fallback: tap any destructive-looking button
let deleteButton = alert.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Log' OR label CONTAINS[c] 'Confirm'")
).firstMatch
if deleteButton.exists {
deleteButton.tap()
}
}
}
}
/// Verify the static_data endpoint supports ETag by hitting it directly.
private func verifyStaticDataEndpointSupportsETag() {
// First request should return 200 with ETag
let firstResult = TestAccountAPIClient.rawRequest(method: "GET", path: "/static_data/")
XCTAssertTrue(firstResult.succeeded, "static_data should return 200")
// Parse ETag from response (we need the raw HTTP headers)
// Use a direct URLRequest to capture the ETag header
guard let url = URL(string: "\(TestAccountAPIClient.baseURL)/static_data/") else {
XCTFail("Invalid URL")
return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 15
let semaphore = DispatchSemaphore(value: 0)
var etag: String?
var secondStatus: Int?
// Fetch ETag
URLSession.shared.dataTask(with: request) { _, response, _ in
defer { semaphore.signal() }
etag = (response as? HTTPURLResponse)?.allHeaderFields["Etag"] as? String
}.resume()
semaphore.wait()
XCTAssertNotNil(etag, "static_data response should include an ETag header")
guard let etagValue = etag else { return }
// Second request with If-None-Match should return 304
var conditionalRequest = URLRequest(url: url)
conditionalRequest.httpMethod = "GET"
conditionalRequest.setValue(etagValue, forHTTPHeaderField: "If-None-Match")
conditionalRequest.timeoutInterval = 15
URLSession.shared.dataTask(with: conditionalRequest) { _, response, _ in
defer { semaphore.signal() }
secondStatus = (response as? HTTPURLResponse)?.statusCode
}.resume()
semaphore.wait()
XCTAssertEqual(
secondStatus, 304,
"static_data with matching ETag should return 304 Not Modified"
)
}
}

View File

@@ -0,0 +1,184 @@
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: AuthenticatedTestCase {
override var useSeededAccount: Bool { true }
// MARK: - DOC-002: Create Document
func testDOC002_CreateDocumentWithRequiredFields() {
// Seed a residence so the document form has a valid residence picker
cleaner.seedResidence()
navigateToDocuments()
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton]
let emptyState = app.otherElements[AccessibilityIdentifiers.Document.emptyStateView]
let documentList = app.otherElements[AccessibilityIdentifiers.Document.documentsList]
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|| emptyState.waitForExistence(timeout: 3)
|| documentList.waitForExistence(timeout: 3)
XCTAssertTrue(loaded, "Documents 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.Document.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
let uniqueTitle = "IntTest Doc \(Int(Date().timeIntervalSince1970))"
titleField.forceTap()
titleField.typeText(uniqueTitle)
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
saveButton.forceTap()
let newDoc = app.staticTexts[uniqueTitle]
XCTAssertTrue(
newDoc.waitForExistence(timeout: longTimeout),
"Newly created document should appear in list"
)
}
// MARK: - DOC-004: Edit Document
func testDOC004_EditDocument() {
// Seed a residence and document via API
let residence = cleaner.seedResidence()
let doc = cleaner.seedDocument(residenceId: residence.id, title: "Edit Target Doc \(Int(Date().timeIntervalSince1970))")
navigateToDocuments()
// Find and tap the seeded document
let card = app.staticTexts[doc.title]
card.waitForExistenceOrFail(timeout: longTimeout)
card.forceTap()
// Tap edit
let editButton = app.buttons[AccessibilityIdentifiers.Document.editButton]
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
editButton.forceTap()
// Update title
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
titleField.forceTap()
titleField.press(forDuration: 1.0)
let selectAll = app.menuItems["Select All"]
if selectAll.waitForExistence(timeout: 2) {
selectAll.tap()
}
let updatedTitle = "Updated Doc \(Int(Date().timeIntervalSince1970))"
titleField.typeText(updatedTitle)
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
saveButton.forceTap()
let updatedText = app.staticTexts[updatedTitle]
XCTAssertTrue(
updatedText.waitForExistence(timeout: longTimeout),
"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))"
)
navigateToDocuments()
// Open the seeded document's detail
let docText = app.staticTexts[document.title]
docText.waitForExistenceOrFail(timeout: longTimeout)
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)
XCTAssertTrue(detailLoaded, "Document detail view should load after tapping the document")
// Look for an images / photos section header or add-image button.
// The exact identifier or label will depend on the document detail implementation.
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)
// This assertion will fail gracefully if the images section is not yet implemented.
// When it does fail, it surfaces the missing UI element for the developer.
XCTAssertTrue(
sectionVisible,
"Document detail should show an images/photos section or an add-image button. " +
"Full deletion of a specific image requires manual upload first — 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)
navigateToDocuments()
let target = app.staticTexts[deleteTitle]
target.waitForExistenceOrFail(timeout: longTimeout)
target.forceTap()
let deleteButton = app.buttons[AccessibilityIdentifiers.Document.deleteButton]
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
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: shortTimeout) {
confirmButton.tap()
} else if alertDelete.waitForExistence(timeout: shortTimeout) {
alertDelete.tap()
}
let deletedDoc = app.staticTexts[deleteTitle]
XCTAssertTrue(
deletedDoc.waitForNonExistence(timeout: longTimeout),
"Deleted document should no longer appear"
)
}
}

View File

@@ -0,0 +1,257 @@
import XCTest
final class OnboardingTests: BaseUITestCase {
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).tap()
nameField.typeText("My Test Home")
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() {
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.forceTap()
onbUsernameField.typeText(creds.username)
onbEmailField.waitForExistenceOrFail(timeout: defaultTimeout)
onbEmailField.forceTap()
onbEmailField.typeText(creds.email)
onbPasswordField.waitForExistenceOrFail(timeout: defaultTimeout)
onbPasswordField.forceTap()
onbPasswordField.typeText(creds.password)
onbConfirmPasswordField.waitForExistenceOrFail(timeout: defaultTimeout)
onbConfirmPasswordField.forceTap()
onbConfirmPasswordField.typeText(creds.password)
// 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)
verificationScreen.waitForLoad(timeout: longTimeout)
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: longTimeout)
|| 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)
login.waitForLoad(timeout: defaultTimeout)
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: longTimeout)
|| 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
// Give the app a moment to settle on its landing screen
sleep(2)
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 loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
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"
)
}
}

View File

@@ -0,0 +1,204 @@
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 {
private var testSession: TestSession?
override func setUpWithError() throws {
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
}
// Create a verified account via API so we have real credentials for reset
guard let session = TestAccountManager.createVerifiedAccount() else {
throw XCTSkip("Could not create verified test account")
}
testSession = session
try super.setUpWithError()
}
// 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)
resetScreen.waitForLoad(timeout: longTimeout)
}
// 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
TestFlows.completeForgotPasswordFlow(
app: app,
email: session.user.email,
newPassword: newPassword
)
// Wait for success indication - either success message or return to login
let successText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'success' OR label CONTAINS[c] 'reset'")
).firstMatch
let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton]
let deadline = Date().addingTimeInterval(longTimeout)
var succeeded = false
while Date() < deadline {
if successText.exists || returnButton.exists {
succeeded = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
XCTAssertTrue(succeeded, "Expected success indication after password reset")
// If return to login button appears, tap it
if returnButton.exists && returnButton.isHittable {
returnButton.tap()
}
// Verify we can login with the new password via API
let loginResponse = TestAccountAPIClient.login(
username: session.username,
password: newPassword
)
XCTAssertNotNil(loginResponse, "Should be able to login with new password after reset")
}
// 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)
resetScreen.waitForLoad(timeout: longTimeout)
}
// 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()
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]
let deadline = Date().addingTimeInterval(longTimeout)
var resetSucceeded = false
while Date() < deadline {
if successText.exists || returnButton.exists {
resetSucceeded = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
XCTAssertTrue(resetSucceeded, "Expected success indication after password reset")
// If the return-to-login button is present, tap it to go back to the login screen
if returnButton.exists && returnButton.isHittable {
returnButton.tap()
}
// Confirm the new password works by logging in via the API
let loginResponse = TestAccountAPIClient.login(
username: session.username,
password: newPassword
)
XCTAssertNotNil(loginResponse, "Should be able to login with the new password after a successful reset")
}
// 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)
resetScreen.waitForLoad(timeout: longTimeout)
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")
}
}

View File

@@ -0,0 +1,31 @@
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 {
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)
}
func testR003_createAccountExpandedFormFieldsAreInteractable() throws {
throw XCTSkip("Skeleton: implement deterministic focus assertions for username/email/password fields")
}
func testR004_emailFieldCanFocusAndAcceptTyping() throws {
throw XCTSkip("Skeleton: implement replacement for legacy email focus failure")
}
func testR005_createAccountContinueOnlyAfterValidInputs() throws {
throw XCTSkip("Skeleton: validate disabled/enabled state transition for Create Account")
}
}

View File

@@ -0,0 +1,72 @@
import XCTest
/// Rebuild plan for legacy failures in Suite1_RegistrationTests:
/// - test07, test09, test10, test11, test12
/// Coverage is split into smaller tests for easier isolation.
final class Suite1_RegistrationRebuildTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
func testR101_registerFormCanOpenFromLogin() {
UITestHelpers.ensureOnLoginScreen(app: app)
let register = TestFlows.openRegisterFromLogin(app: app)
register.waitForLoad(timeout: defaultTimeout)
}
func testR102_registerFormAcceptsValidInput() {
UITestHelpers.ensureOnLoginScreen(app: app)
let register = TestFlows.openRegisterFromLogin(app: app)
XCTAssertTrue(app.textFields[UITestID.Auth.registerUsernameField].exists)
XCTAssertTrue(app.textFields[UITestID.Auth.registerEmailField].exists)
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerPasswordField].exists)
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerConfirmPasswordField].exists)
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists)
}
func testR103_successfulRegistrationTransitionsToVerificationGate() throws {
throw XCTSkip("Skeleton: submit valid registration and assert verification gate")
}
func testR104_verificationGateBlocksMainAppBeforeCodeEntry() throws {
throw XCTSkip("Skeleton: assert no tab bar access while unverified")
}
func testR105_validVerificationCodeTransitionsToMainApp() throws {
throw XCTSkip("Skeleton: use deterministic verification code fixture and assert main app root")
}
func testR106_mainAppSessionAfterVerificationCanReachProfile() throws {
throw XCTSkip("Skeleton: assert verified user can navigate tab bar and profile")
}
func testR107_invalidVerificationCodeShowsErrorAndStaysBlocked() throws {
throw XCTSkip("Skeleton: replacement for legacy test09")
}
func testR108_incompleteVerificationCodeDoesNotCompleteVerification() throws {
throw XCTSkip("Skeleton: replacement for legacy test10")
}
func testR109_verifyButtonDisabledForIncompleteCode() throws {
throw XCTSkip("Skeleton: optional split from legacy test10 button state assertion")
}
func testR110_relaunchUnverifiedUserNeverLandsInMainApp() throws {
throw XCTSkip("Skeleton: replacement for legacy test11")
}
func testR111_relaunchUnverifiedUserResumesVerificationOrLoginGate() throws {
throw XCTSkip("Skeleton: acceptable states after relaunch")
}
func testR112_logoutFromVerificationReturnsToLogin() throws {
throw XCTSkip("Skeleton: replacement for legacy test12")
}
func testR113_verificationElementsDisappearAfterLogout() throws {
throw XCTSkip("Skeleton: split assertion from legacy test12")
}
func testR114_logoutFromVerifiedMainAppReturnsToLogin() throws {
throw XCTSkip("Skeleton: split assertion from legacy test07 cleanup")
}
}

View File

@@ -0,0 +1,147 @@
import XCTest
/// Rebuild plan for legacy Suite2 failures:
/// - test02_loginWithValidCredentials
/// - test06_logout
final class Suite2_AuthenticationRebuildTests: BaseUITestCase {
override var includeResetStateLaunchArgument: Bool { false }
override var additionalLaunchArguments: [String] { ["--ui-test-mock-auth"] }
private let validUser = RebuildTestUserFactory.seeded
private enum AuthLandingState {
case main
case verification
}
override func setUpWithError() throws {
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: longTimeout) || 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: longTimeout)
case .verification:
RebuildSessionAssertions.assertOnVerification(app, timeout: longTimeout)
}
}
func testR204_mainAppHasExpectedPrimaryTabsAfterLogin() {
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
switch landing {
case .main:
RebuildSessionAssertions.assertOnMainApp(app, timeout: longTimeout)
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: longTimeout)
}
func testR206_postLogoutMainAppIsNoLongerAccessible() {
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
switch landing {
case .main:
logoutFromMainApp()
case .verification:
logoutFromVerificationIfNeeded()
}
RebuildSessionAssertions.assertOnLogin(app, timeout: longTimeout)
XCTAssertFalse(app.otherElements[UITestID.Root.mainTabs].exists, "Main app root should not be visible after logout")
}
}

View File

@@ -0,0 +1,137 @@
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 additionalLaunchArguments: [String] { ["--ui-test-mock-auth"] }
override func setUpWithError() throws {
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()
let main = MainTabScreenObject(app: app)
main.waitForLoad(timeout: longTimeout)
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: longTimeout), "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: longTimeout), "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: longTimeout).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)
}
}

View File

@@ -0,0 +1,227 @@
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: AuthenticatedTestCase {
override var useSeededAccount: Bool { true }
// 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: longTimeout),
"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()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
// Find and tap the seeded residence
let card = app.staticTexts[seeded.name]
card.waitForExistenceOrFail(timeout: longTimeout)
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: longTimeout),
"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()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
// Open the second residence's detail
let secondCard = app.staticTexts[secondResidence.name]
secondCard.waitForExistenceOrFail(timeout: longTimeout)
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: longTimeout)
|| 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: longTimeout)
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()
let residenceList = ResidenceListScreen(app: app)
residenceList.waitForLoad(timeout: defaultTimeout)
// Find and tap the seeded residence
let target = app.staticTexts[deleteName]
target.waitForExistenceOrFail(timeout: longTimeout)
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: shortTimeout) {
confirmButton.tap()
} else if alertDelete.waitForExistence(timeout: shortTimeout) {
alertDelete.tap()
}
let deletedResidence = app.staticTexts[deleteName]
XCTAssertTrue(
deletedResidence.waitForNonExistence(timeout: longTimeout),
"Deleted residence should no longer appear in the list"
)
}
}

View File

@@ -0,0 +1,165 @@
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: shortTimeout) && 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)
}
}
// MARK: - OFF-003: Retry Button Existence
/// OFF-003: Retry button is accessible from error states.
///
/// A true end-to-end retry test (where the network actually fails then succeeds)
/// is not feasible in XCUITest without network manipulation infrastructure. This
/// test verifies the structural requirement: that the retry accessibility identifier
/// `AccessibilityIdentifiers.Common.retryButton` is defined and that any error view
/// in the app exposes a tappable retry control.
///
/// When an error view IS visible (e.g., backend is unreachable), the test asserts the
/// retry button exists and can be tapped without crashing the app.
func testP010_retryButtonExistsOnErrorState() {
// Navigate to the login screen from onboarding this is the most common
// path that could encounter an error state if the backend is unreachable.
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad(timeout: defaultTimeout)
welcome.tapAlreadyHaveAccount()
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
// Attempt login with intentionally wrong credentials to trigger an error state
login.enterUsername("nonexistent_user_off003")
login.enterPassword("WrongPass!")
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
// Wait briefly to allow any error state to appear
sleep(3)
// Check for error view and retry button
let retryButton = app.buttons[AccessibilityIdentifiers.Common.retryButton]
let errorView = app.otherElements[AccessibilityIdentifiers.Common.errorView]
// If an error view is visible, assert the retry button is also present and tappable
if errorView.exists {
XCTAssertTrue(
retryButton.waitForExistence(timeout: shortTimeout),
"Retry button (\(AccessibilityIdentifiers.Common.retryButton)) should exist when an error view is shown"
)
XCTAssertTrue(
retryButton.isEnabled,
"Retry button should be enabled so the user can re-attempt the failed operation"
)
// Tapping retry should not crash the app
retryButton.forceTap()
sleep(1)
XCTAssertTrue(app.exists, "App should remain running after tapping retry")
} else {
// No error view is currently visible this is acceptable if login
// shows an inline error message instead. Confirm the app is still in a
// usable state (it did not crash and the login screen is still present).
let stillOnLogin = app.textFields[UITestID.Auth.usernameField].exists
let showsAlert = app.alerts.firstMatch.exists
let showsErrorText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'incorrect' OR label CONTAINS[c] 'error'")
).firstMatch.exists
XCTAssertTrue(
stillOnLogin || showsAlert || showsErrorText,
"After a failed login the app should show an error state — login screen, alert, or inline error"
)
}
}
}

View File

@@ -0,0 +1,231 @@
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: AuthenticatedTestCase {
override var useSeededAccount: Bool { true }
// 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]
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()
titleField.typeText(uniqueTitle)
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
saveButton.forceTap()
let newTask = app.staticTexts[uniqueTitle]
XCTAssertTrue(
newTask.waitForExistence(timeout: longTimeout),
"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()
// Find the cancelled task
let taskText = app.staticTexts[cancelledTask.title]
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()
// The cancelled task should be visible somewhere on the tasks screen
// (e.g., in a Cancelled column or section)
let taskText = app.staticTexts[task.title]
guard taskText.waitForExistence(timeout: longTimeout) 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-004: Create Task from Template
func test16_createTaskFromTemplate() throws {
// Seed a residence so template-created tasks have a valid target
cleaner.seedResidence(name: "Template Test Residence \(Int(Date().timeIntervalSince1970))")
navigateToTasks()
// Tap the add task button (or empty-state equivalent)
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
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, "An add/create task button should be visible on the tasks screen")
if addButton.exists && addButton.isHittable {
addButton.forceTap()
} else {
emptyAddButton.forceTap()
}
// Look for a Templates or Browse Templates option within the add-task flow.
// NOTE: The exact accessibility identifier for the template browser is not yet defined
// in AccessibilityIdentifiers.swift. The identifiers below use the pattern established
// in the codebase (e.g., "TaskForm.TemplatesButton") and will need to be wired up in
// the SwiftUI view when the template browser feature is implemented.
let templateButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Template' OR label CONTAINS[c] 'Browse'")
).firstMatch
guard templateButton.waitForExistence(timeout: defaultTimeout) else {
throw XCTSkip("Template browser not yet reachable from the add-task flow — skipping")
}
templateButton.forceTap()
// Select the first available template
let firstTemplate = app.cells.firstMatch
guard firstTemplate.waitForExistence(timeout: defaultTimeout) else {
throw XCTSkip("No templates available in template browser — skipping")
}
firstTemplate.forceTap()
// After selecting a template the form should be pre-filled the title field should
// contain something (i.e., not be empty)
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
let preFilledTitle = titleField.value as? String ?? ""
XCTAssertFalse(
preFilledTitle.isEmpty,
"Title field should be pre-filled by the selected template"
)
// Save the templated task
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
saveButton.forceTap()
// The task should now appear in the list
let savedTask = app.staticTexts[preFilledTitle]
XCTAssertTrue(
savedTask.waitForExistence(timeout: longTimeout),
"Task created from template ('\(preFilledTitle)') should appear in the task list"
)
}
// MARK: - TASK-012: Delete Task
func testTASK012_DeleteTaskUpdatesViews() {
// Seed a task via API
let residence = cleaner.seedResidence()
let task = cleaner.seedTask(residenceId: residence.id, title: "Delete Task \(Int(Date().timeIntervalSince1970))")
navigateToTasks()
// Find and open the task
let taskText = app.staticTexts[task.title]
taskText.waitForExistenceOrFail(timeout: longTimeout)
taskText.forceTap()
// Delete the task
let deleteButton = app.buttons[AccessibilityIdentifiers.Task.deleteButton]
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
deleteButton.forceTap()
// Confirm deletion
let confirmDelete = app.alerts.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
).firstMatch
let alertConfirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
if alertConfirmButton.waitForExistence(timeout: shortTimeout) {
alertConfirmButton.tap()
} else if confirmDelete.waitForExistence(timeout: shortTimeout) {
confirmDelete.tap()
}
let deletedTask = app.staticTexts[task.title]
XCTAssertTrue(
deletedTask.waitForNonExistence(timeout: longTimeout),
"Deleted task should no longer appear in views"
)
}
}

View File

@@ -0,0 +1,174 @@
import XCTest
/// Reusable helper functions for UI tests
struct UITestHelpers {
private static func loginUsernameField(app: XCUIApplication) -> XCUIElement {
app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
}
// MARK: - Authentication Helpers
/// Logs out the user if they are currently logged in
/// - Parameter app: The XCUIApplication instance
static func logout(app: XCUIApplication) {
sleep(1)
// Already on login screen.
let usernameField = loginUsernameField(app: app)
if usernameField.waitForExistence(timeout: 2) {
return
}
// In onboarding flow, navigate to login.
let onboardingRoot = app.otherElements[UITestID.Root.onboarding]
if onboardingRoot.waitForExistence(timeout: 2) {
ensureOnLoginScreen(app: app)
return
}
// Check if we have a tab bar (logged in state)
let tabBar = app.tabBars.firstMatch
guard tabBar.exists else { return }
// Navigate to Residences tab first
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
if residencesTab.exists {
residencesTab.tap()
sleep(1)
}
// Tap settings button
let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
if settingsButton.waitForExistence(timeout: 3) && settingsButton.isHittable {
settingsButton.tap()
sleep(1)
}
// Find and tap logout button
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
if logoutButton.waitForExistence(timeout: 3) {
logoutButton.tap()
sleep(1)
// Confirm logout in alert if present - specifically target the alert's button
let alert = app.alerts.firstMatch
if alert.waitForExistence(timeout: 2) {
let confirmLogout = alert.buttons["Log Out"]
if confirmLogout.exists {
confirmLogout.tap()
}
}
}
sleep(2)
XCTAssertTrue(
usernameField.waitForExistence(timeout: 8),
"Failed to log out - login username field should appear"
)
}
/// Logs in a user with the provided credentials
/// - Parameters:
/// - app: The XCUIApplication instance
/// - username: The username/email to use for login
/// - password: The password to use for login
static func login(app: XCUIApplication, username: String, password: String) {
// Find username field by accessibility identifier
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Username field should exist")
usernameField.tap()
usernameField.typeText(username)
// Find password field - it could be TextField (if visible) or SecureField
var passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
if !passwordField.exists {
passwordField = app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
}
XCTAssertTrue(passwordField.waitForExistence(timeout: 3), "Password field should exist")
passwordField.tap()
passwordField.typeText(password)
// Find and tap login button
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
XCTAssertTrue(loginButton.waitForExistence(timeout: 3), "Login button should exist")
loginButton.tap()
// Wait for login to complete
sleep(3)
}
/// Ensures the user is logged out before running a test
/// - Parameter app: The XCUIApplication instance
static func ensureLoggedOut(app: XCUIApplication) {
sleep(1)
logout(app: app)
ensureOnLoginScreen(app: app)
}
/// Ensures the user is logged in with test credentials before running a test
/// - Parameter app: The XCUIApplication instance
/// - Parameter username: Optional username (defaults to "testuser")
/// - Parameter password: Optional password (defaults to "TestPass123!")
static func ensureLoggedIn(app: XCUIApplication, username: String = "testuser", password: String = "TestPass123!") {
sleep(1)
// Check if already logged in (tab bar visible)
let tabBar = app.tabBars.firstMatch
if tabBar.exists {
return // Already logged in
}
ensureOnLoginScreen(app: app)
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
if usernameField.waitForExistence(timeout: 5) {
login(app: app, username: username, password: password)
// Wait for main screen to appear
_ = tabBar.waitForExistence(timeout: 10)
}
}
static func ensureOnLoginScreen(app: XCUIApplication) {
let usernameField = loginUsernameField(app: app)
if usernameField.waitForExistence(timeout: 2) {
return
}
// Handle persisted authenticated sessions first.
let mainTabsRoot = app.otherElements[UITestID.Root.mainTabs]
if mainTabsRoot.exists || app.tabBars.firstMatch.exists {
logout(app: app)
if usernameField.waitForExistence(timeout: 8) {
return
}
}
// Wait for a stable root state before interacting.
let loginRoot = app.otherElements[UITestID.Root.login]
let onboardingRoot = app.otherElements[UITestID.Root.onboarding]
_ = loginRoot.waitForExistence(timeout: 5) || onboardingRoot.waitForExistence(timeout: 5)
if onboardingRoot.exists {
// Handle both pure onboarding and onboarding + login sheet.
let onboardingLoginButton = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
if onboardingLoginButton.waitForExistence(timeout: 5) {
if onboardingLoginButton.isHittable {
onboardingLoginButton.tap()
} else {
onboardingLoginButton.forceTap()
}
} else {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapAlreadyHaveAccount()
}
}
XCTAssertTrue(
usernameField.waitForExistence(timeout: 20),
"Expected to reach login screen from current app state"
)
}
}